diff --git a/.memory/android-localization.md b/.memory/android-localization.md index d90a4807f..67c69af65 100644 --- a/.memory/android-localization.md +++ b/.memory/android-localization.md @@ -6,4 +6,4 @@ All user-visible Android copy in composables lives in `android/app/src/main/res/ - Composables use `stringResource` / `pluralStringResource`. Copy built inside `semantics {}`, `clearAndSetSemantics {}`, non-composable label lambdas (`AidenSegmentedPillRow`'s `label`), or `CustomAccessibilityAction` is hoisted into a `val` first. Copy built in coroutines or local functions uses `val resources = LocalResources.current` and `resources.getString`. Notifications use `Context.getString`. - Counts use ``; sentences take `%1$s` / `%1$d` arguments rather than concatenation. Small enums and private label helpers carry `@StringRes` ids (`AidenUsageTokenKind`, `AidenBotEditorAccessMode`, `AidenBotChatAccessScope`, `AidenRunInputModeOption`, `AgentRunActivityStatus.titleRes`). - English values stay byte-identical to what UI tests match with `onNodeWithText`. `chat_progress_steps_done` keeps "steps" and seven placeholders keep "..." with `tools:ignore` for that reason. -- Deliberately still English: ViewModel / controller / state-holder error fallbacks (`AidenChatViewModel`, `AidenWorkspaceHomeViewModel`, `AidenBotsViewModel`, `ComposerVoiceInputController`, `AidenBotGeneratedAvatarModel`, `AidenBotChatToolsModel`, `AidenChatForkErrors`); JVM-tested presentation helpers (`AidenChatRowStatusPresentation`, `aidenRelativeTimestamp`, `aidenFolderCrumbs`, Usage date/inspection labels, `AidenReadAloudProgress.label`, `AidenImageCountBadge.label`, `AidenTurnElapsed`, `AidenMessageTimestamp`); model-layer titles (`AidenWorkspacePermission`, `AidenScheduledTaskFilter`, `AidenApprovalPresentation`, `AidenScheduledTaskPresentation`); protocol values, ids, test tags, and the dev-only `features/bots/prototype`. Moving these needs a resource-id contract change and test updates. +- Deliberately still English: ViewModel / controller / state-holder error fallbacks (`AidenChatViewModel`, `AidenWorkspaceHomeViewModel`, `AidenBotsViewModel`, `ComposerVoiceInputController`, `AidenBotGeneratedAvatarModel`, `AidenBotChatToolsModel`, `AidenChatForkErrors`); JVM-tested presentation helpers (`AidenChatRowStatusPresentation`, `aidenRelativeTimestamp`, `aidenFolderCrumbs`, Usage date/inspection labels, `AidenReadAloudProgress.label`, `AidenImageCountBadge.label`, `AidenTurnElapsed`, `aidenBotElapsedLabel` and `aidenBotDurationLabel` (Bot status line elapsed time, `AidenBotActivity.kt`), `AidenMessageTimestamp`); model-layer titles (`AidenWorkspacePermission`, `AidenScheduledTaskFilter`, `AidenApprovalPresentation`, `AidenScheduledTaskPresentation`); protocol values, ids, test tags, and the dev-only `features/bots/prototype`. Moving these needs a resource-id contract change and test updates. diff --git a/.memory/bot-chat-activity.md b/.memory/bot-chat-activity.md index aeb99476c..d3eab52f9 100644 --- a/.memory/bot-chat-activity.md +++ b/.memory/bot-chat-activity.md @@ -15,7 +15,7 @@ Spec: docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md (§17 lists - Onboarding: the Bots gallery tile (`BotsArt`: name pill, one exchange, routine chip) shows neither the list nor the Updates line. C2 changed only its header (panel toggle instead of a back chevron). ## For slice D (phones) -- Remote wire is unchanged by B. D adds `activity` (phase, app ≤ 40, startedAt only) to the session stream and `chatActivity` to detail/PATCH behind `bot-activity-v1`; reuse `botActivityDisplay`, `botElapsedLabel` and `nextBotAnnouncement` semantics and the §5 table as the shared fixture. +- Remote wire is unchanged by B (D has since shipped; see "Slice D (phones)" below). D adds `activity` (phase, app ≤ 40, startedAt only) to the session stream and `chatActivity` to detail/PATCH behind `bot-activity-v1`; reuse `botActivityDisplay`, `botElapsedLabel` and `nextBotAnnouncement` semantics and the §5 table as the shared fixture. - Remote Stop bypasses the `bots:stop` handler, so a phone Stop doesn't show Stopping on the Mac; D's planned `botTurnIntents` covers it. A phone Resume already shows "Picking up where it left off" on the Mac (the same runtime resume fires `onResumed`). - Choosing Chat activity changes the Bot revision, so a phone's next identity edit gets `revision_conflict` and refreshes (safe). @@ -45,3 +45,52 @@ Spec: docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md (§17 lists - Test harness: Bot renderer suites run with `NODE_ENV=test` (`renderer/main/bots/test-env.ts`, first import of `test-dom.ts`, which throws if the router's server build loaded anyway) so TanStack Router behaves as on a client and routed pages re-render; `mountWithBotRouter` takes `sidebar` and mounts inside `act`. Wrap test navigations, sleeps and emitted notifications in `act` (bot-sidebar, bot-chat-pane, bots-home and bot-panel print no act warnings). - e2e: `tests/e2e/bots-sidebar-panel.spec.ts` (1280×800: the panel leaves the composer uncovered; the workspace surface is inert; `bots:attention` reaches a row through the app's own sync). - Parked: workspace sidebar remount loses its local state after a Bots visit; batched `bots:live:summaries` IPC (N reads on mount); Profile→Chat pushes history (pre-existing); remote-only shows "Meet your first Bot"; the device mini player reconnects while suppressed (pre-existing); panel close returns focus to the toggle, not the composer (briefed); chip names are basenames (pre-existing); test debt: `lastNonBotRoute` module-state seam and edge cases, dead `deferred()` lines in the Files keep-on-refresh test, the render probe never uninstalls, Task 2 review minors 3, 4 and 6. + +## Slice D (phones) — what shipped (2026-10-14, Remote revision 30 provisional) +Plan: docs/superpowers/plans/2026-10-14-bot-chat-activity-d-phones.md. Spec §11, §12 and §17 (Slice D rulings) hold the contract and every ruling (R1–R22); the working ledger is `.superpowers/sdd/2026-10-14-bot-chat-activity-d-phones/progress.md` (git-ignored). Branch `claude/bot-chat-activity-d-phones`. Public contract: docs/aiden-remote-api-v1.md ("Bot chat activity, revision 30"). + +### Wire +- Token `bot-activity-v1` (announced only while activity frames, `whenBusy` and `chatActivity` are wired together) and the negotiable, authority-free `bot:activity` opt-in (any device type, 404 when unwired, never issued by pairing). Older phones' decoders refuse unknown keys and event types, so the token alone can't gate the host; holders get the new shapes, everyone else gets a no-op `state` frame at the same seqs. +- Session `snapshot` keys `activity` (`{phase, app?, startedAt}` or null) and `queued` (`[{submissionId, mode: steer|queue, text}]`, at most 20, text at most 32,000 code points), frames `activity` and `queued`, and `submissionId` on the person's own user entries (R16). `app` at most 40 code points (only for the `app` phase); `startedAt` an integer 0..2^53-1; nothing else from the step crosses. +- Bot detail `chatActivity`; `PATCH /bots/{id}` accepts it (exact keys); `POST /bots/{id}/messages` accepts `whenBusy: steer|queue` (runtime `steer|followUp`). Detail routes strip `chatActivity` for non-holders at one choke point (`writeBotDetail`); the fixture parser requires revision >= 30 for `botActivity`. +- Photo-only queued messages are omitted from `queued` (R11). The list is built by inclusion from ids the desktop's fail-closed classifier marked personal (R1): routine and intro inputs never reach a phone. + +### Host +- The Remote projector (`main/services/aiden-remote-bot-session.ts`, `BotLiveProjector`) reuses B's `liveBotActivity`/`liveQueuedMessages` derivations and `liveRunKey` (R4: a changed run key resets `startedAt`, Stop and Resume). It stays silent, deriving nothing, unless `turnIntents` and `bots.supportsChatActivity` are both present, so revision-29 behaviour is byte-for-byte unchanged. `supportsActivity` is fixed at construction. +- Run start (final wave FR-I1): `createBotRunStarts` in `live-projection.ts`; the singleton `botRunStarts` (in `bot-turn-intents-main.ts`) is passed to both the desktop projection (`bot-session-main.ts`) and `AidenRemoteBotSessionService` (`runStarts`, in `aiden-remote-service-main.ts`). One record per Bot keyed by `liveRunKey`, recorded by the first surface to see the run and asked on every running frame (so a null key adopts the run's key as soon as it shows); any surface seeing the Bot not running ends it. Without it a projector created mid-run or after the 60 s idle close reset the phone's clock. Nothing durable records a run start, so a run already going at app start is timed from first sight. +- Queued classification: `createQueuedInputClassifier` (`live-projection.ts`) is the one classify-once, fail-closed, throttled-retry implementation; the desktop feed and each projector own one and pass their own lane as `retry`. `liveQueuedMessages(docs, personal)` is an inclusion filter (required set of the person's ids). `resumedFrom` is stored serialized (`JSON.stringify`) once when the Resume is heard. +- Remote ledger (R22b): `AidenOperationNotExecutedError` (`aiden-remote-operation-contract.ts`) makes the idempotency ledger forget an admission and rethrow its cause. Remote send/Resume/Dismiss/Stop wrap only the runtime call (`unlessRefusedBeforeRunning`) for `BotSessionError` `needs_model`, `model_error`, `unavailable`, `bot_paused`, which are all thrown before any write. Other routes using `executeIdempotent` are unchanged (a rejection keeps its record: a contract error replays under its own code, a non-contract error as `internal_error`, and an unknown outcome stays in flight). +- `botTurnIntents` (`main/services/bot-runtime/bot-turn-intents.ts`, singleton in `bot-turn-intents-main.ts`) is the one Stop/Resume source for the desktop projection and Remote: intents `stopping`, `stopCancelled` (R3, a failed Stop) and `resuming`. `stopWithIntents` is shared by `bots:stop` and Remote Stop; `wireBotLiveTurnIntents` (tested like `wireBotAttention`) feeds the desktop projection. Resume is announced only from the runtime's `onResumed` (R2), never before `runtime.resume`. Listener errors are logged and never block the others. +- B Resume fix (`resumedFrom`): `BotLiveActivityInput.resumedFrom` is the `pi.live` doc when the Resume began; partial text that predates it no longer counts as writing, so "Picking up where it left off" holds until the first live event after the Resume. Shared by the desktop and Remote. +- `submissionId` tagging (R16): the host resolves placed submissions into a memo keyed by entry id, sticky while the entry is in the projected view and pruned when it leaves (eviction must never emit an `entry` frame, or phones, including rev-29 ones, would grow a ghost bubble). Only the person's own inputs are tagged; stripped for non-holders. A failing lookup is reported once per streak; `submissionTagsHeld(botId)` is the test seam for the memo bound. + +### Phones (iOS and Android behave the same; R14 "everything optimistic, nothing waits to connect") +- Phase tables: TS `renderer/shared/bot-activity.ts`; iOS `AidenBotActivityPhase.table` in `ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift`; Android `AidenBotActivityPhase` in `features/bots/AidenBotActivity.kt` with words in `strings.xml` (`bot_activity_*`). The shared fixture `botActivity.phases` in `protocol/aiden-remote/v1/fixtures/contract.json` pins them: every id, word and mark, plus unknown id -> `working`. +- Status line: shows from the send ("Getting ready"), hides only while reply text streams with no active tool step (R7), Reconnecting while the stream is down in a running turn, elapsed after 20 s, announcements throttled. No 2-minute note, no steps, recap or peek on phones. Steer/Queue pill (default Queue, Stop where it was), Queued bubbles faded, glancing faces on Bots home, photo avatars wear a glance badge; R19: under Reduce Motion/Low Power canonical faces keep a still badge (both phones). +- Optimistic outbox (R14, R17, R18): sends and Retry go through a per-Bot outbox that survives leaving the chat, a Bot switch and relaunch; a request is frozen at first attempt; reconcile is by `submissionId` (text only as fallback), one entry settles one message, an unsettled message is hidden not removed, head-of-line blocking, ambiguous failures retry 1 s then 4 s while connected. Stop/Resume/Dismiss/answers/approvals/proposals apply at once and roll back per field. Skeletons and a quiet "Connecting…" hint instead of connect-blocking screens. +- **On-disk unsent text.** Unsent message text lives on disk for up to 24 h. iOS: Application Support `BotOutbox-v1`, file protection `completeUntilFirstUserAuthentication`, excluded from backup, at most 20 messages, 10 MB. Android: `noBackupFilesDir/BotOutbox-v1`, `AtomicFile`, never backed up, 20 messages, 4 MB. Keyed by installation identity (a re-pair never restores old messages); purged on unpair, revoked credentials and access loss (401/403); expired or exhausted messages show "Couldn't send · Try again", never silently dropped; a bad record is dropped alone. +- **12 s request bounds.** Every Bot request on the phones is bounded to 12 s (iOS `aidenBotWithin(requestLimit)`; Android `withTimeoutOrNull`), because the URL session otherwise waits minutes on a dead link. A Chat activity pick made while connecting is held up to 8 s. +- **Chat activity page** (Advanced → Chat activity, shown only when `supportsBotActivity`): native `Form` on iOS (`AidenBotAdvancedView.swift`, no new Swift files and no pbxproj edits), Material 3 Expressive on Android (`AidenBotChatActivityScreen.kt`, `AidenBotChatActivityModel.kt`, `AidenBotChatActivityPreviewCard.kt`). Looping previews, level-specific Reduce Motion still frames, paused when inactive or off-screen. Picks save instantly, coalesce, roll back on failure; a `revision_conflict` or timeout refreshes the Bot and adopts the Mac's choice; the row opens offline from the cached Bot. Android picks run on the coordinator's scope (`launchDetached`) so they outlive the dialog, and are reported to the screens behind once. +- **iOS rule:** no new Swift files (and no pbxproj edits) in D; everything lives in the existing Bot files (`AidenBotSessionModel.swift`, `AidenBotSessionChatView.swift`, `AidenBotAdvancedView.swift`, `AidenBotSemanticAvatarView.swift`, `AidenBotGeneratedAvatarLifecycle.swift`, `AidenBotsHomeView.swift`, `Models/AidenBot*.swift`). +- **Android strings.** All copy is in `strings.xml` (`bot_activity_*`, `bot_chat_activity_*`, `bot_session_*` status/queued strings). `aidenBotElapsedLabel` and `aidenBotDurationLabel` (`AidenBotActivity.kt`) are deliberately English (JVM-tested helpers); see `.memory/android-localization.md`. + +### Tests +- Desktop (registered, no new files): `main/services/aiden-remote-protocol.test.ts`, `aiden-remote-operation-contract.test.ts`, `aiden-remote-router.test.ts` (incl. R22b: refused while paused, resumed, same key sends), `aiden-remote-state.test.ts`, `aiden-remote-pairing.test.ts`, `aiden-remote-bots.test.ts`, `aiden-remote-bot-session.test.ts` (incl. mid-run and post-idle-close `startedAt` shared with the desktop feed), `main/services/bot-runtime/live-projection.test.ts` (intents, Stop isolation, `resumedFrom`). +- iOS: `AidenBotSessionTests.swift` (session model, outbox, `AidenBotChatActivityTests`, fixture decode), `AidenBotContractTests.swift`, `AidenRemoteClientTests.swift`, `AidenRemotePhase0Tests.swift`. +- Android: `AidenBotActivityContractTest`, `AidenBotRevision25ClientTest`, `AidenBotSessionControllerTest`, `AidenBotChatActivityTest`, `AidenBotChatPresentationTest`. +- Not run: the manual end-to-end pass against a real Mac (pair, Stop, Wi-Fi toggle, Queued, Chat activity round trip, revision-29 Mac) and VoiceOver/TalkBack. + +### Re-claim revision 30 at merge +If `main` has moved past 29, change every `30` D introduced to the next free revision, and update iOS, Android and the fixture together: `AIDEN_REMOTE_CONTRACT_REVISION`, the fixture's two `contractRevision` lines, the fixture parser's `< 30` check, the asserts in `aiden-remote-protocol.test.ts`, `aiden-remote-operation-contract.test.ts`, `aiden-remote-router.test.ts` and `AidenRemotePhase0Tests.swift`, the comments naming "revision 30", `docs/aiden-remote-api-v1.md`, the spec §11 heading, `.memory/bots-rework-ui.md` and this file. + +### Final fix wave (2026-10-14) +- Desktop and protocol, done: FR-I1 run start, FR-m6 classify clock, R22(b) ledger, shared classifier and inclusion filter, serialized `resumedFrom`, once-per-streak `placed()` reports, and the test gaps (reconnect snapshot mid-run, 40-code-point app cut, identical phase emits nothing, partial-wiring gate, memo bound, no fixed `setTimeout(50)`). Spec and API doc corrected: the detail routes that strip `chatActivity` are create, GET, PATCH and avatar DELETE (no restore or archive routes; avatar PUT returns asset metadata). +- Fixture: `botActivityPrivacy.rejectedNullLiterals` (`activities`, `sessions`, `events`, `entries`); every decoder must refuse an optional key sent as the literal null. +- iOS, done (b0d8cfc4c, 04b623430; spec §17 has the detail): FR-m1 clock gate, FR-m4 copy, FR-m5 snapshot grace (unheld mark, dropped at a later reconcile 10 s after it; Android to match), FR-m7, R19 still badge, R21 (`AidenInstallation.botActivityAnnounced` persisted; `aidenBotActivitySupported(server:installation:)` falls back to the cache until `/server` answers), R15 (New Bot, model pick, Advanced Save, Profile and Memory edits apply at once, skeletons, rollback, 12 s bounds, create idempotency kept), and the chat and Chat activity polish. Open: the Files sheet `ProgressView` in `AidenBotChatToolsView`; no VoiceOver or live-Mac pass. +- Android, done (e2f678024, 7533f2096, 237e9e861; 892 JVM tests, 12/12 Bot instrumented tests on a headless AVD): FR-I2 unreadable outbox renamed aside with "Some unsent messages couldn’t be restored."; FR-I3 every Bot session request bounded to 12 s (timeout is ambiguous, key kept); FR-m2 Mac answers applied by write counters; FR-m3 asks for `bot:activity` only when `serverCapabilities` offers it; FR-m4 shared copy matches iOS and lives in `strings.xml`; FR-m5 unheld grace as on iOS; FR-m8 elapsed exposed to TalkBack (the word is announced, the per-second state is not); R21 completion (persisted `bot-activity-v1` token plus grant stand in until `/server` answers); R15 Profile, Memory, model pick and Advanced Save apply at once with rollback and skeletons; Chat activity pin by (instanceId, deviceId, createdAt). Open on Android: the fixture null-literal raw-null guard; Bot create still waits on the form ("Creating…", bounded, key replayed after a timeout) instead of an optimistic Home row; inline error text (`palette.danger`) is about 3:1 in some light themes. + +### Parked (full list in spec §17) +- Pre-merge bug sweep (PR #479 review and follow-ups), done: Remote — `model_error` is a 409 state refusal (bbd13274e); snapshot and every frame fit the 1 MiB SSE limit by priority allocation (f78443bae, 7aebccfe5). iOS — Chat activity picks stay with their pairing (25574c1ae); a send before the outbox restore never replaces the last launch's file (cc7ae5c6b); Try Again replays the frozen Create through a catalog change (9a2feca96); outbox diagnostics through the typed wrapper (65e00968b); a restore landing after an access-loss purge is dropped (7744dd31b); a New Bot cut off by a relaunch comes back with Try Again (9685204a9, 89b91a071); Files sheet skeletons (7eddc5be8); bounded test waits (d4d63580d); Chat activity reports after leaving and keeps a pick made during a timed-out save (4030c06a5, 978caa670). Android — a restore after an access-loss purge is dropped (e0202df7a); unconfirmed edits never reach the Bot cache's disk (6352bfbad); null literals refused (ea29ffe2f); optimistic New Bot with a Creating… row and same-key Try again, surviving process death (c47d018f6, 212f69254, e348b0131); scheme-derived error text at ≥4.5:1 (4de4bf33f). Test harness — the core-git EPIPE race (3d3cbc959). +- Still open: a manual VoiceOver/TalkBack pass and an end-to-end run against a real Mac on this branch. +- Overlapping Stops where one fails briefly clear "Stopping" (cosmetic). No Remove for queued messages on phones (no Remote withdraw route). Chat activity picks have no durable queue. + diff --git a/.memory/bots-rework-ui.md b/.memory/bots-rework-ui.md index dc53e7561..8c4e37a72 100644 --- a/.memory/bots-rework-ui.md +++ b/.memory/bots-rework-ui.md @@ -47,6 +47,7 @@ See `.memory/bot-chat-activity.md` for the status line, Chat activity and recaps ## Feature tokens and revision - Revision **27** (2026-10-09, Bot memory/soul/proactivity, re-claim at merge; `main` was 26): tokens `bot-memory-v1` and `bot-proactive-v1`, negotiable `bot:cards` gating `memory_update`/`routine_proposal` session entries, routes for memory, proposal answers, routine suggestions and `GET /bots/routine-notifications`, and `openingGreeting` retired (never emitted, accepted and ignored). Details: `.memory/bot-proactivity.md`. +- Revision **30** (2026-10-14, Bot chat activity on phones, provisional, re-claim at merge; `main` was 29): token `bot-activity-v1`, negotiable `bot:activity` gating the `activity` and `queued` session frames and snapshot keys, `submissionId` on the person's user entries, and Bot detail `chatActivity`; `PATCH /bots/{id}` accepts `chatActivity` and `POST /bots/{id}/messages` accepts `whenBusy: steer|queue` only while the token is announced. Phones keep a persisted, TTL'd, file-protected outbox of unsent text. Details: `.memory/bot-chat-activity.md`; contract: `docs/aiden-remote-api-v1.md`. - Aiden Remote contract revision **25** (`main` is at 24). Tokens in `aiden-remote-protocol.ts`: `bot-delete-v1`, `bot-durable-session-v1`, `bot-routines-v1`, `bot-connection-requests-v1`, `bot-presets-v1`. iOS (`AidenBotSessionModel.swift`) and Android consume the same tokens, and fixtures are shared across TS, Swift and Kotlin. - Routes added in revision 25: `POST /bots/{id}/resume` and `/dismiss` (idempotent per request UUID), `DELETE /bots/{id}`, `GET/POST/PATCH/DELETE /bots/{id}/routines`, `POST /bots/{id}/connection-requests`, `GET /bot-presets`, `POST /bots/from-preset`. - Remote audit (2026-10-07): `/server.features` announces each token only while its route is wired (router test "revision 25 Bot feature tokens…"; before this the tokens were never sent). `GET /bots/{id}/session` and `/session/events` need `chat:read` as well as `bot:read`; the stream crosses the revocation fence and `revokeDevice` closes it; a rebuilt Remote API closes the old session service. diff --git a/.memory/revisited-generation-controls.md b/.memory/revisited-generation-controls.md index 27fde7217..b91969d97 100644 --- a/.memory/revisited-generation-controls.md +++ b/.memory/revisited-generation-controls.md @@ -19,3 +19,20 @@ Hosted verification also exercised the broader Environment/Subagents source cont Focused renderer/IPC tests, chat/composer suite, and held-response Electron regressions cover revisit → queue/steer → next turn and revisit → Stop → new turn. E2E held-response sidebar titles reflect the original prompt, not the deterministic completion text. Local native helper scripts discard inherited SDK variables; on this host a direct Xcode 26.5 SDK helper build was used for E2E because the CommandLineTools macOS 27 SDK fails linking. Review: OpenCode Workers created an isolated checkout at the exact feature commit, but its launcher rejected `--dir` under the configured OpenCode v2 CLI. A direct read-only DeepSeek V4 Flash run in that same worktree reviewed the full diff and surrounding lifecycle/queue contracts, reported no actionable findings, and left the worktree unchanged. The requested “4.1 Flash” name was not listed by OpenCode; V4 Flash was used as the available model. + +## October 15: chat-message-queue CI flakes (PR #479) + +Two held-response specs flake about once per few dozen hosted runs and never locally: "Stop after revisiting…" (Stop missing after the revisit, line ~617; sibling "revisiting a running chat…" had the same symptom on Oct 9) and "a response finishing while the queue editor is open…" (0 requests with the edited text, line ~511). They predate Bot slice C2 (#478): the editor test flaked on Oct 10 and Stop-after-revisit on Oct 13 (mcp-07 branch, head bcc1845d) — so C2's route memory, Bot panel provider and Bot notification sync are ruled out. + +Not reproduced: ~1,300 local runs (macOS, 8 workers, CPU stress, CDP 20x renderer throttle, background QoS, SIGSTOP stalls of main/renderer, slow fs, delayed IPC replies and idle notifications, focus loss, gap sweeps) plus 360 runs in a Linux container under xvfb. Reading the code, both symptoms need the first held response to end (a terminal or error) while the test thinks it is still running: a terminal hides and drops the detached projection (Stop gone), and `onError` pauses the chat queue (edited row never sent). The trigger on hosted runners is unknown. + +Fixed along the way: after a revisited chat's detached run ended, the watcher started on revisit mirrored that same run back as an "observed" (phone) run because the only ownership check was the detached marker the terminal had just cleared. The composer re-locked ("Waiting for the response…") right after unlocking, for 10–300 ms (11 of 12 local probe runs; 0 of 12 after). `rememberOwnGenerationStream` in `chat-terminal-sync.ts` (called from `startGeneration` and on detach) now keeps a bounded set of this document's runs that are never mirrored. + +Diagnostics for the next hosted failure: the E2E fixture prints, on failure, the on-screen alerts/status/toasts and the dev log's warn/error records to stderr (the reporter truncates the dev-log attachment, and hosted artifacts keep only the sanitized receipt). The editor spec also asserts the first reply saved and the queue is not paused before Save, so an upstream failure no longer reads as an editor bug. + +Also found by loading the whole spec (6 workers locally), where the Steer specs failed 8 of 30 before and 0 of 60 after (whole spec, 4 workers, repeat-each 30: 570 of 570): +- Steer during the start window was rejected. Stop and the Steer pill appear as soon as a send begins, but Pi opens its run-input queue only at `agent_start`, so main answered `run_not_active` and the composer said "The current response has ended". `admitRunInputOnceStarted` (`chat-message-queue.ts`) now retries `run_not_active` while the pane's own run is still live (bounded to about 8.5 s; a rejected attempt writes nothing). Detached runs are not retried. +- `handleSend` awaited the `chat:start` acknowledgement. That held the composer's `sending` flag, so the busy pill's `canSubmit` was false while main initialized, and picking Steer or Queue only switched modes. The send now finishes at the durable append; a start rejection still sets the pane error. +- Test race: after picking from the busy pill's menu, Radix returns focus to the chevron a task later, so a keypress sent straight to the composer could land on the trigger and reopen the menu. The rejected-steer spec now waits for that focus first. + +Noise seen in every local run: "Could not append Pi session messages for stream …" logged three times at error level on each user Stop. diff --git a/android/app/src/androidTest/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatSemanticsUiTest.kt b/android/app/src/androidTest/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatSemanticsUiTest.kt new file mode 100644 index 000000000..b60eaa6d7 --- /dev/null +++ b/android/app/src/androidTest/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatSemanticsUiTest.kt @@ -0,0 +1,83 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import sbtbiswas.AidenOnTheGo.models.AidenBotSession +import sbtbiswas.AidenOnTheGo.models.AidenBotSessionActivity +import sbtbiswas.AidenOnTheGo.models.AidenBotSessionState +import sbtbiswas.AidenOnTheGo.ui.theme.AidenTheme + +/** What TalkBack hears from the Bot chat's status line and its unsent messages. */ +@RunWith(AndroidJUnit4::class) +class AidenBotChatSemanticsUiTest { + @get:Rule + val compose = createComposeRule() + + private fun running(startedAt: Long) = AidenBotSession( + botId = "bot_fixture_01", + epoch = "epoch_1", + seq = 1, + state = AidenBotSessionState.RUNNING, + interrupted = false, + entries = emptyList(), + hasOlder = false, + question = null, + approval = null, + activity = AidenBotSessionActivity("reading", null, startedAt) + ) + + // FR-m8: the word is the line's name and the elapsed time its state, as on iOS. + @Test + fun theStatusLineIsNamedByItsWordWithTheElapsedTimeAsItsState() { + val ui = AidenBotSessionUiState(session = running(System.currentTimeMillis() - 75_000), activitySupported = true, hasLoaded = true) + // The mark and the elapsed clock tick forever: hold the clock so the test can settle. + compose.mainClock.autoAdvance = false + compose.setContent { AidenTheme { AidenBotStatusLine(ui) } } + compose.mainClock.advanceTimeBy(1_000) + val node = compose.onNodeWithTag(AidenBotStatusLineTags.LINE).fetchSemanticsNode() + assertEquals(listOf("Reading"), node.config.getOrNull(SemanticsProperties.ContentDescription)) + val state = node.config.getOrNull(SemanticsProperties.StateDescription) + assertTrue("elapsed state was $state", state != null && Regex("^1m 1[5-7]s$").matches(state)) + } + + @Test + fun underTwentySecondsTheLineHasNoElapsedState() { + val ui = AidenBotSessionUiState(session = running(System.currentTimeMillis() - 5_000), activitySupported = true, hasLoaded = true) + // The mark and the elapsed clock tick forever: hold the clock so the test can settle. + compose.mainClock.autoAdvance = false + compose.setContent { AidenTheme { AidenBotStatusLine(ui) } } + compose.mainClock.advanceTimeBy(1_000) + compose.onNodeWithTag(AidenBotStatusLineTags.LINE) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.StateDescription)) + } + + // A failed message says so as its state, and Try again is its own control. + @Test + fun aFailedMessageCarriesItsStateAndOffersTryAgain() { + val message = AidenBotOutgoingMessage.create("Plan lunch", null, enqueuedWhileRunning = false, retryOf = null, nowMs = 0) + .copy(status = AidenBotOutgoingStatus.FAILED) + var retried = 0 + compose.setContent { + AidenTheme { + AidenBotOutgoingBubble(message, AidenBotOutgoingLook.Failed, canRetry = true, onRetry = { retried += 1 }, onRemove = {}) + } + } + compose.onNodeWithText("Plan lunch", useUnmergedTree = false) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "Couldn’t send")) + compose.onNodeWithText("Try again").assertHasClickAction().performClick() + compose.runOnIdle { assertEquals(1, retried) } + } +} diff --git a/android/app/src/androidTest/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCreatePendingUiTest.kt b/android/app/src/androidTest/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCreatePendingUiTest.kt new file mode 100644 index 000000000..ee2a0207b --- /dev/null +++ b/android/app/src/androidTest/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCreatePendingUiTest.kt @@ -0,0 +1,85 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import sbtbiswas.AidenOnTheGo.auth.InMemoryAidenSecureStore +import sbtbiswas.AidenOnTheGo.features.remote.AidenRemoteCoordinator +import sbtbiswas.AidenOnTheGo.persistence.AidenInstallationStore +import sbtbiswas.AidenOnTheGo.ui.theme.AidenTheme +import java.io.File + +/** R15: New Bot closes at once; Bots home shows the Bot on its way, and a failure offers Try Again. */ +@RunWith(AndroidJUnit4::class) +class AidenBotCreatePendingUiTest { + @get:Rule + val compose = createComposeRule() + + private lateinit var storage: File + private lateinit var coordinator: AidenRemoteCoordinator + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + storage = File(context.cacheDir, "bot-create-pending-${System.nanoTime()}").apply { mkdirs() } + coordinator = AidenRemoteCoordinator( + installationStore = AidenInstallationStore(storage, InMemoryAidenSecureStore()), + storageDir = storage, + // Never connected: no Mac answers, so a create can only wait and fail. + scope = CoroutineScope(Dispatchers.Unconfined + Job().apply { cancel() }) + ) + } + + @After + fun tearDown() { + storage.deleteRecursively() + } + + @Test + fun aBotOnItsWayShowsOnBotsHomeAndAFailureReopensNewBotAsItWas() { + val flow = coordinator.botCreateFlow() + val viewModel = compose.runOnIdle { AidenBotsViewModel(coordinator, coordinator.botCache) } + var reopened = 0 + compose.setContent { + AidenTheme { + AidenBotsHomeScreen( + coordinator = coordinator, + viewModel = viewModel, + onNavigateToChat = {}, + onNavigateToBotProfile = {}, + onNavigateToCreateBot = { reopened += 1 } + ) + } + } + + val draft = AidenBotCreateDraft(name = "Meal Planner", help = "Plans dinners") + val attempt = compose.runOnIdle { flow.start(draft, null)!! } + compose.onNodeWithText("Meal Planner").assertIsDisplayed() + compose.onNodeWithText("Creating…").assertIsDisplayed() + + // No Mac to reach: the row goes and the failure says so. + runBlocking { flow.send(attempt) } + compose.onNodeWithText("Creating…").assertDoesNotExist() + compose.onNodeWithText("Couldn’t Create Bot").assertIsDisplayed() + compose.onNodeWithText("Try Again").performClick() + + compose.runOnIdle { + assertEquals(1, reopened) + assertEquals("New Bot opens with the same answers", draft, flow.reopening()) + } + compose.onNodeWithText("Couldn’t Create Bot").assertDoesNotExist() + } +} diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/AidenAppContainer.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/AidenAppContainer.kt index 7ba8c6bb6..30713d243 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/AidenAppContainer.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/AidenAppContainer.kt @@ -48,7 +48,8 @@ class AidenAppContainer(context: Context) { draftStore = draftStore, navigationStore = navigationStore, intentCatalogStore = intentCatalogStore, - scope = scope + scope = scope, + botOutboxDir = appContext.noBackupFilesDir ) private val settingsCache = AidenSettingsCache(File(filesDir, "settings_cache")) val settingsStore = AidenSettingsStore(settingsCache, scope) diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt new file mode 100644 index 000000000..36d4cc059 --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt @@ -0,0 +1,560 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.currentStateAsState +import kotlinx.coroutines.delay +import sbtbiswas.AidenOnTheGo.R +import sbtbiswas.AidenOnTheGo.config.AidenPalette +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMark +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMarkKind +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMarkProperty +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.spec +import sbtbiswas.AidenOnTheGo.models.AidenBotChatActivity +import sbtbiswas.AidenOnTheGo.models.AidenBotChatActivityWording +import sbtbiswas.AidenOnTheGo.models.AidenBotDetail +import sbtbiswas.AidenOnTheGo.models.AidenBotQueuedMessage +import sbtbiswas.AidenOnTheGo.models.AidenServer +import sbtbiswas.AidenOnTheGo.models.AidenStreamInputMode +import sbtbiswas.AidenOnTheGo.ui.theme.AidenMotion +import sbtbiswas.AidenOnTheGo.ui.theme.AidenTheme +import sbtbiswas.AidenOnTheGo.ui.theme.AidenUi +import sbtbiswas.AidenOnTheGo.ui.theme.aidenReduceMotion + +/** + * The status line's words and marks (spec §5), in §5's order. Kept in step with + * `renderer/shared/bot-activity.ts` and iOS by the shared fixture's `botActivity.phases`. + */ +enum class AidenBotActivityPhase(val id: String, @StringRes val word: Int, val mark: AidenActivityMarkKind) { + PREPARING("preparing", R.string.bot_activity_preparing, AidenActivityMarkKind.BOUNCE), + THINKING("thinking", R.string.bot_activity_thinking, AidenActivityMarkKind.TRI_STEP), + READING("reading", R.string.bot_activity_reading, AidenActivityMarkKind.SCAN_GRID), + PHOTO("photo", R.string.bot_activity_photo, AidenActivityMarkKind.SCAN_GRID), + SEARCHING("searching", R.string.bot_activity_searching, AidenActivityMarkKind.SCAN_GRID), + COMMAND("command", R.string.bot_activity_command, AidenActivityMarkKind.QUAD_SHUFFLE), + EDITING("editing", R.string.bot_activity_editing, AidenActivityMarkKind.QUAD_SHUFFLE), + APP("app", R.string.bot_activity_app, AidenActivityMarkKind.QUAD_SHUFFLE), + HELPER("helper", R.string.bot_activity_helper, AidenActivityMarkKind.QUAD_SHUFFLE), + COMPUTER("computer", R.string.bot_activity_computer, AidenActivityMarkKind.QUAD_SHUFFLE), + REMEMBERING("remembering", R.string.bot_activity_remembering, AidenActivityMarkKind.COMPOSE), + ROUTINE("routine", R.string.bot_activity_routine, AidenActivityMarkKind.TRI_STEP), + WORKING("working", R.string.bot_activity_working, AidenActivityMarkKind.QUAD_SHUFFLE), + WRITING("writing", R.string.bot_activity_writing, AidenActivityMarkKind.COMPOSE), + WAITING("waiting", R.string.bot_activity_waiting, AidenActivityMarkKind.GLANCE), + TIDYING("tidying", R.string.bot_activity_tidying, AidenActivityMarkKind.BOUNCE), + STOPPING("stopping", R.string.bot_activity_stopping, AidenActivityMarkKind.BOUNCE), + RESUMING("resuming", R.string.bot_activity_resuming, AidenActivityMarkKind.BOUNCE), + RECONNECTING("reconnecting", R.string.bot_activity_reconnecting, AidenActivityMarkKind.BOUNCE); + + companion object { + private val byId = entries.associateBy { it.id } + + /** An id this build doesn't know reads as Working (§5.2). */ + fun of(id: String): AidenBotActivityPhase = byId[id] ?: WORKING + } +} + +/** These change what the person should do, so Always "Thinking" keeps them. */ +private val KEEPS_ITS_WORD = setOf(AidenBotActivityPhase.WAITING, AidenBotActivityPhase.STOPPING, AidenBotActivityPhase.RECONNECTING) + +/** What the line shows: a phase plus, for "Using {app}", the connection's label. */ +data class AidenBotActivityDisplay(val phase: AidenBotActivityPhase, val app: String?) { + val mark: AidenActivityMarkKind get() = phase.mark +} + +fun aidenBotActivityDisplay(phase: String, wording: AidenBotChatActivityWording, app: String?): AidenBotActivityDisplay { + val known = AidenBotActivityPhase.of(phase) + val shown = if (wording == AidenBotChatActivityWording.THINKING && known !in KEEPS_ITS_WORD) AidenBotActivityPhase.THINKING else known + return AidenBotActivityDisplay(shown, if (shown == AidenBotActivityPhase.APP) app?.takeIf { it.isNotBlank() } else null) +} + +@Composable +fun AidenBotActivityDisplay.word(): String = + if (app != null) stringResource(R.string.bot_activity_app_named, app) else stringResource(phase.word) + +const val AIDEN_BOT_ELAPSED_AFTER_MS = 20_000L + +/** "18s", "1m 12s", "2m", "1h 2m": the Mac's wording (a JVM-tested presentation helper, like `AidenTurnElapsed`). */ +fun aidenBotDurationLabel(ms: Long): String { + val seconds = maxOf(1L, ms / 1_000) + if (seconds < 60) return "${seconds}s" + val minutes = seconds / 60 + val rest = seconds % 60 + if (minutes < 60) return if (rest > 0) "${minutes}m ${rest}s" else "${minutes}m" + val hours = minutes / 60 + val remainder = minutes % 60 + return if (remainder > 0) "${hours}h ${remainder}m" else "${hours}h" +} + +/** Null before 20 seconds, and while this phone's clock reads behind the Mac's. */ +fun aidenBotElapsedLabel(startedAt: Long, nowMs: Long): String? { + val elapsed = nowMs - startedAt + return if (elapsed >= AIDEN_BOT_ELAPSED_AFTER_MS) aidenBotDurationLabel(elapsed) else null +} + +data class AidenBotAnnouncement(val text: String, val atMs: Long) + +/** [shown] is what to announce now; [retryInMs] says when to offer the newest text again. */ +data class AidenBotAnnouncementStep(val shown: AidenBotAnnouncement?, val retryInMs: Long?) + +/** Screen readers hear a status change at most once every [gapMs] (§14). */ +fun aidenBotNextAnnouncement(current: AidenBotAnnouncement?, text: String, nowMs: Long, gapMs: Long = 2_000): AidenBotAnnouncementStep { + if (current != null && current.text == text) return AidenBotAnnouncementStep(current, null) + if (current == null || nowMs - current.atMs >= gapMs) return AidenBotAnnouncementStep(AidenBotAnnouncement(text, nowMs), null) + return AidenBotAnnouncementStep(current, gapMs - (nowMs - current.atMs)) +} + +/** The canonical face's glance (docs/activity-marks.md `glance`): [dxFraction] of the avatar's size; [blink] scales the eyes. */ +data class AidenBotFaceGlance(val dxFraction: Float, val blink: Float) + +private val GLANCE = AidenActivityMarkKind.GLANCE.spec() + +fun aidenBotFaceGlance(timeMs: Long): AidenBotFaceGlance { + val dx = GLANCE.groups[0].track(AidenActivityMarkProperty.TRANSLATE_X)?.valueAt(timeMs) ?: 0f + val blink = GLANCE.shapes[0].track(AidenActivityMarkProperty.SCALE_Y)?.valueAt(timeMs) ?: 1f + // The mark's view box is 24 wide. + return AidenBotFaceGlance(dx / 24f, blink) +} + +/** + * A Bot's Chat activity as this phone may use it: only while the Mac announces + * `bot-activity-v1` and this phone holds `bot:activity` now. A detail cached from another + * pairing or before an older Mac was restored never decides what the chat shows. + */ +fun aidenBotChatActivity(server: AidenServer?, detail: AidenBotDetail?, cachedSupport: Boolean = false): AidenBotChatActivity? = + if (aidenBotActivitySupported(server, cachedSupport)) detail?.chatActivity else null + +// --- The chat's presentation (R14, R19): pure, so the JVM suite can hold it --- + +/** The quiet hint above a Bot chat's composer while the Mac isn't reachable (R14). */ +enum class AidenBotConnectionHint { + /** "Connecting…": no snapshot yet, or the feed dropped. */ + CONNECTING, + /** The first read failed: say so with Try again. The chat stays usable and keeps reconnecting. */ + UNREACHABLE, + /** The Mac refused this phone (401/403) on the first read: no "Mac nearby", and no Try again. */ + NO_ACCESS +} + +/** + * The hint to show, or null when connected. While a turn runs the status line already says + * "Reconnecting to your Mac", so the hint stays out of its way; a Bot gone from the Mac says + * that instead. + */ +fun aidenBotConnectionHint(ui: AidenBotSessionUiState, nowMs: Long): AidenBotConnectionHint? { + if (ui.botMissing || !ui.isConnecting) return null + if (aidenBotStatusLine(ui, nowMs)?.display?.phase == AidenBotActivityPhase.RECONNECTING) return null + return when { + !ui.hasLoaded && ui.accessLost -> AidenBotConnectionHint.NO_ACCESS + !ui.hasLoaded && ui.loadFailed -> AidenBotConnectionHint.UNREACHABLE + else -> AidenBotConnectionHint.CONNECTING + } +} + +/** Transcript placeholders while a chat opens with nothing cached. */ +enum class AidenBotSkeletonLook { HIDDEN, SHIMMERING, STILL } + +/** Skeletons shimmer while the first read is on its way and hold still once it has failed, never a spinner. */ +fun aidenBotSkeletonLook(ui: AidenBotSessionUiState): AidenBotSkeletonLook = when { + !ui.showsSkeleton || ui.botMissing -> AidenBotSkeletonLook.HIDDEN + ui.loadFailed -> AidenBotSkeletonLook.STILL + else -> AidenBotSkeletonLook.SHIMMERING +} + +/** The small line under a bubble the Mac's transcript doesn't hold yet. */ +enum class AidenBotPendingCaption { QUEUED, SENDING, FAILED } + +/** How a waiting bubble draws: [faded] for one not yet in the reply, with an optional caption. */ +data class AidenBotPendingBubble(val faded: Boolean, val caption: AidenBotPendingCaption?) + +/** A Queued message waits for the reply; a steer has already joined it, so it reads as sent. */ +fun aidenBotQueuedBubble(mode: AidenStreamInputMode): AidenBotPendingBubble = + if (mode == AidenStreamInputMode.QUEUE) AidenBotPendingBubble(true, AidenBotPendingCaption.QUEUED) + else AidenBotPendingBubble(false, null) + +fun aidenBotOutgoingBubble(look: AidenBotOutgoingLook): AidenBotPendingBubble = when (look) { + AidenBotOutgoingLook.Sending -> AidenBotPendingBubble(true, AidenBotPendingCaption.SENDING) + AidenBotOutgoingLook.Sent -> AidenBotPendingBubble(false, null) + is AidenBotOutgoingLook.Queued -> aidenBotQueuedBubble(look.mode) + AidenBotOutgoingLook.Failed -> AidenBotPendingBubble(false, AidenBotPendingCaption.FAILED) +} + +data class AidenBotBubbleColors(val container: Color, val content: Color) + +/** + * The Bot screens' quiet text: [caption] for "Sending…", "Queued" and the connection hint, + * [link] for Try again, [error] for an inline error ("Couldn't save", a destructive menu label). + * All come from the scheme and read at 4.5:1 or better in every theme where they are drawn: the + * accent alone doesn't in some light themes, and neither does the danger colour (about 3:1). + */ +data class AidenBotChatTextColors(val caption: Color, val link: Color, val error: Color) + +fun aidenBotChatTextColors(scheme: ColorScheme): AidenBotChatTextColors = AidenBotChatTextColors( + caption = scheme.onSurfaceVariant, + link = scheme.secondary, + // The page, its cards and sheets, and an error's own tint. + error = aidenReadableInk( + scheme.error, + toward = scheme.onSurface, + on = listOf(scheme.background, scheme.surface, scheme.surfaceContainer, scheme.surfaceBright, scheme.errorContainer) + ) +) + +/** + * [ink] moved toward [toward] just far enough to read at [ratio] on every colour in [on], so it + * keeps as much of its own colour as it can. [toward] itself if nothing short of it does. + */ +fun aidenReadableInk(ink: Color, toward: Color, on: List, ratio: Float = 4.5f): Color { + val steps = 40 + for (step in 0..steps) { + val candidate = androidx.compose.ui.graphics.lerp(ink, toward, step / steps.toFloat()) + if (on.all { AidenPalette.contrastRatio(candidate, it) >= ratio }) return candidate + } + return toward +} + +/** [AidenBotChatTextColors.error] for the theme in use: inline error text on the Bot screens. */ +@Composable +fun aidenBotErrorTextColor(): Color { + val scheme = MaterialTheme.colorScheme + return remember(scheme) { aidenBotChatTextColors(scheme).error } +} + +/** + * Whether new rows should scroll the chat to its end: only when the person is already at (or + * within [slack] rows of) the end, or just sent something themselves. Reading back stays put. + */ +fun aidenBotFollowsTranscriptEnd(lastVisibleIndex: Int, totalCount: Int, justSent: Boolean, slack: Int = 2): Boolean = + justSent || totalCount == 0 || lastVisibleIndex >= totalCount - 1 - slack + +/** + * The person's bubble: the accent fill once it's in the reply, the tonal accent container + * while it waits. The container pair is opaque, so the words keep their contrast; the bubble + * is never dimmed with alpha. + */ +fun aidenBotUserBubbleColors(scheme: ColorScheme, faded: Boolean): AidenBotBubbleColors = + if (faded) AidenBotBubbleColors(scheme.primaryContainer, scheme.onPrimaryContainer) + else AidenBotBubbleColors(scheme.primary, scheme.onPrimary) + +/** The Steer/Queue choice: the person's pick while the Bot works, Queue again once the turn ends. */ +fun aidenBotBusyMode(current: AidenStreamInputMode, isRunning: Boolean): AidenStreamInputMode = + if (isRunning) current else AidenStreamInputMode.QUEUE + +/** + * How a Bot's avatar says it is working (R8, R19). A canonical face glances; a photo can't, + * and a face held still by Reduce Motion or battery saver can't either, so both wear the + * glance badge instead, which holds its still pose when motion is off. Nothing moves while + * the app isn't in front. + */ +data class AidenBotFaceMotion(val glances: Boolean, val badge: Boolean, val badgeAnimates: Boolean) + +fun aidenBotFaceMotion( + working: Boolean, + photo: Boolean, + reduceMotion: Boolean, + powerSave: Boolean, + resumed: Boolean +): AidenBotFaceMotion { + if (!working) return AidenBotFaceMotion(glances = false, badge = false, badgeAnimates = false) + val still = reduceMotion || powerSave + val badge = photo || still + return AidenBotFaceMotion(glances = !photo && !still && resumed, badge = badge, badgeAnimates = badge && !still && resumed) +} + +/** True while the app is in front; marks, faces and shimmers hold still otherwise. */ +@Composable +internal fun aidenBotIsResumed(): Boolean { + val state by LocalLifecycleOwner.current.lifecycle.currentStateAsState() + return state.isAtLeast(Lifecycle.State.RESUMED) +} + +/** Holds the last line shown, so the line fades out with its words rather than blank. */ +private class AidenBotLastLine(var line: AidenBotStatusLineState? = null) + +/** Tags for UI tests. */ +object AidenBotStatusLineTags { + const val LINE = "bot_status_line" +} + +/** + * The status line while a Bot works (spec §12): a native activity mark and the shared word, + * plus the elapsed time after 20 seconds. No steps and no Stop (Stop stays in the top bar). + * Its accessible name is the word alone and the elapsed time is its state (as iOS's + * accessibility value, FR-m8). The word is announced at most once every 2 seconds, and the + * elapsed time never is; the announcer starts over each time the line hides. + */ +@Composable +fun AidenBotStatusLine(ui: AidenBotSessionUiState) { + val palette = AidenTheme.palette + val reduceMotion = aidenReduceMotion() + val resumed = aidenBotIsResumed() + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + val startedAt = ui.session?.activity?.startedAt + // Elapsed ticks once a second while a run has a start time, and rests in the background. + LaunchedEffect(startedAt, resumed) { + if (startedAt == null || !resumed) return@LaunchedEffect + while (true) { + now = System.currentTimeMillis() + delay(1_000) + } + } + val line = aidenBotStatusLine(ui, now) + val last = remember { AidenBotLastLine() } + if (line != null) last.line = line + val word = line?.display?.word() + var announced by remember { mutableStateOf(null) } + val view = LocalView.current + // An explicit announcement, like iOS: the line's own node changes every second with its elapsed state. + LaunchedEffect(announced?.text) { + @Suppress("DEPRECATION") + announced?.text?.let { view.announceForAccessibility(it) } + } + LaunchedEffect(word) { + if (word == null) { + announced = null + return@LaunchedEffect + } + val step = aidenBotNextAnnouncement(announced, word, System.currentTimeMillis()) + announced = step.shown + step.retryInMs?.let { + delay(it) + announced = aidenBotNextAnnouncement(announced, word, System.currentTimeMillis()).shown + } + } + AnimatedVisibility( + visible = line != null, + enter = fadeIn(AidenMotion.short(reduceMotion)) + expandVertically(AidenMotion.spatial(reduceMotion)), + exit = fadeOut(AidenMotion.short(reduceMotion)) + shrinkVertically(AidenMotion.spatial(reduceMotion)) + ) { + val shown = line ?: last.line ?: return@AnimatedVisibility + val shownWord = shown.display.word() + // The mark grows with the person's text size. + val markSize = 18.dp * LocalDensity.current.fontScale + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 2.dp) + .testTag(AidenBotStatusLineTags.LINE) + .clearAndSetSemantics { + contentDescription = shownWord + shown.elapsed?.let { stateDescription = it } + } + ) { + AidenActivityMark(mark = shown.display.mark, size = markSize, active = resumed, color = palette.secondary) + Text( + text = shown.elapsed?.let { stringResource(R.string.bot_activity_status_elapsed, shownWord, it) } ?: shownWord, + style = MaterialTheme.typography.bodyMedium, + color = palette.secondary + ) + } + } +} + +/** A person's bubble the Mac's transcript doesn't hold yet, aligned like their sent ones. */ +@Composable +private fun AidenBotPendingBubbleView( + text: String, + faded: Boolean, + modifier: Modifier = Modifier, + caption: @Composable () -> Unit = {} +) { + val colors = aidenBotUserBubbleColors(MaterialTheme.colorScheme, faded) + Column(horizontalAlignment = Alignment.End, modifier = modifier.fillMaxWidth()) { + Surface(color = colors.container, shape = AidenBotUserBubbleShape, modifier = Modifier.widthIn(max = 320.dp)) { + Text( + text, + style = MaterialTheme.typography.bodyLarge, + color = colors.content, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp) + ) + } + caption() + } +} + +@Composable +private fun AidenBotPendingCaptionText(text: String) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + color = aidenBotChatTextColors(MaterialTheme.colorScheme).caption, + modifier = Modifier.padding(top = 2.dp, end = 4.dp) + ) +} + +/** The person's waiting message: faded with "Queued", or a steer shown as a normal bubble. */ +@Composable +fun AidenBotQueuedBubble(message: AidenBotQueuedMessage) { + AidenBotQueuedLook(message.text, message.mode) +} + +@Composable +private fun AidenBotQueuedLook(text: String, mode: AidenStreamInputMode) { + val look = aidenBotQueuedBubble(mode) + val hint = stringResource(R.string.bot_session_queued_hint) + val queued = look.caption == AidenBotPendingCaption.QUEUED + AidenBotPendingBubbleView( + text = text, + faded = look.faded, + modifier = Modifier.semantics(mergeDescendants = true) { if (queued) stateDescription = hint } + ) { + if (queued) AidenBotPendingCaptionText(stringResource(R.string.bot_session_queued)) + } +} + +/** + * A message the person sent from this phone, before the Mac's transcript shows it (R14): + * faded with "Sending…", Queued while the Bot works, or "Couldn't send" with Try again and + * remove, each a full touch target. + */ +@Composable +fun AidenBotOutgoingBubble( + message: AidenBotOutgoingMessage, + look: AidenBotOutgoingLook, + canRetry: Boolean, + onRetry: () -> Unit, + onRemove: () -> Unit +) { + val palette = AidenTheme.palette + if (look is AidenBotOutgoingLook.Queued) { + AidenBotQueuedLook(message.text, look.mode) + return + } + val bubble = aidenBotOutgoingBubble(look) + val text = aidenBotChatTextColors(MaterialTheme.colorScheme) + when (bubble.caption) { + AidenBotPendingCaption.FAILED -> { + val failed = stringResource(R.string.bot_session_send_failed) + Column(Modifier.fillMaxWidth()) { + // TalkBack reads the message with its state, then offers Try again and remove. + AidenBotPendingBubbleView( + message.text, + faded = bubble.faded, + modifier = Modifier.semantics(mergeDescendants = true) { stateDescription = failed } + ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.align(Alignment.End)) { + Icon(Icons.Outlined.ErrorOutline, contentDescription = null, tint = palette.danger, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(failed, style = MaterialTheme.typography.labelMedium, color = text.caption, modifier = Modifier.clearAndSetSemantics {}) + Text( + stringResource(R.string.bot_session_send_failed_separator), + style = MaterialTheme.typography.labelMedium, + color = text.caption, + modifier = Modifier.padding(start = 4.dp).clearAndSetSemantics {} + ) + TextButton( + onClick = onRetry, + enabled = canRetry, + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = Modifier.heightIn(min = AidenUi.MinimumTouchTarget) + ) { + Text(stringResource(R.string.bot_session_try_again), style = MaterialTheme.typography.labelLarge, color = if (canRetry) text.link else text.caption) + } + IconButton(onClick = onRemove, modifier = Modifier.size(AidenUi.MinimumTouchTarget)) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.bot_session_remove_message), tint = text.caption, modifier = Modifier.size(18.dp)) + } + } + } + } + AidenBotPendingCaption.SENDING -> AidenBotPendingBubbleView( + message.text, + faded = bubble.faded, + modifier = Modifier.semantics(mergeDescendants = true) {} + ) { + AidenBotPendingCaptionText(stringResource(R.string.bot_session_sending)) + } + else -> AidenBotPendingBubbleView(message.text, faded = bubble.faded) + } +} + +/** + * A quiet line above the composer while the Mac isn't reachable, never a blocking screen (R14). + * It slides in and out; Try again shows it is trying at once (the hint goes back to "Connecting…"). + */ +@Composable +fun AidenBotConnectionHintRow(hint: AidenBotConnectionHint?, onTryAgain: () -> Unit) { + val reduceMotion = aidenReduceMotion() + // Holds the last hint so the row fades out with its words rather than blank. + var shown by remember { mutableStateOf(hint) } + if (hint != null) shown = hint + AnimatedVisibility( + visible = hint != null, + enter = fadeIn(AidenMotion.short(reduceMotion)) + expandVertically(AidenMotion.spatial(reduceMotion)), + exit = fadeOut(AidenMotion.short(reduceMotion)) + shrinkVertically(AidenMotion.spatial(reduceMotion)) + ) { + shown?.let { AidenBotConnectionHintContent(it, onTryAgain) } + } +} + +@Composable +private fun AidenBotConnectionHintContent(hint: AidenBotConnectionHint, onTryAgain: () -> Unit) { + val text = aidenBotChatTextColors(MaterialTheme.colorScheme) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(min = 24.dp) + .semantics { liveRegion = LiveRegionMode.Polite } + ) { + when (hint) { + AidenBotConnectionHint.CONNECTING -> Text( + stringResource(R.string.bot_session_connecting), + style = MaterialTheme.typography.labelMedium, + color = text.caption + ) + AidenBotConnectionHint.NO_ACCESS -> Text( + stringResource(R.string.bot_session_load_no_access), + style = MaterialTheme.typography.labelMedium, + color = text.caption, + textAlign = TextAlign.Center + ) + AidenBotConnectionHint.UNREACHABLE -> { + Text( + stringResource(R.string.bot_session_load_failed), + style = MaterialTheme.typography.labelMedium, + color = text.caption, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f, fill = false) + ) + TextButton( + onClick = onTryAgain, + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = Modifier.heightIn(min = AidenUi.MinimumTouchTarget) + ) { + Text(stringResource(R.string.bot_session_try_again), style = MaterialTheme.typography.labelLarge, color = text.link) + } + } + } + } +} diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotAdvancedScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotAdvancedScreen.kt index cf687cce6..520bbf364 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotAdvancedScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotAdvancedScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -14,6 +15,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -30,6 +33,7 @@ import sbtbiswas.AidenOnTheGo.ui.theme.AidenConnectedColumn import sbtbiswas.AidenOnTheGo.ui.theme.AidenGroupCard import sbtbiswas.AidenOnTheGo.ui.theme.AidenPrimaryButton import sbtbiswas.AidenOnTheGo.ui.theme.AidenSegmentedPillRow +import sbtbiswas.AidenOnTheGo.ui.theme.AidenSkeletonBlock import sbtbiswas.AidenOnTheGo.ui.theme.AidenTheme import sbtbiswas.AidenOnTheGo.ui.theme.AidenUi import sbtbiswas.AidenOnTheGo.ui.theme.aidenReadableWidth @@ -148,6 +152,57 @@ data class AidenBotAdvancedDraft( try { accessUpdate(catalog); true } catch (_: Exception) { false } } +/** + * The Bot as it will be once [this] draft saves (R15): the model and access show at once, until + * the Mac's own Bot replaces it. A Bot that needed a model doesn't any more. + */ +fun AidenBotAdvancedDraft.optimistic(bot: AidenBotDetail, catalog: AidenBotCapabilityCatalog): AidenBotDetail { + val update = accessUpdate(catalog) + return bot.copy( + access = bot.access.copy(accessMode = update.accessMode, custom = update.custom), + modelSelection = AidenBotModelSelection(customAccess.providerID, customAccess.modelID), + sessionState = if (bot.sessionState == AidenBotSessionState.NEEDS_MODEL) AidenBotSessionState.IDLE else bot.sessionState + ) +} + +/** + * Advanced's Save, applied at once (R15): [show] gets the optimistic Bot now, and the save runs + * on [host] (outliving the sheet), waiting for the connection and bounded. The Mac's Bot then + * replaces it through [settled]; a failure puts back what was there, unless something newer + * replaced it, and says why through [failed]. Only the Mac's Bot is kept on disk: the + * optimistic one and a rollback are shown, never written. + */ +fun aidenBotSaveAdvancedOptimistically( + host: AidenBotSaveHost, + bot: AidenBotDetail, + draft: AidenBotAdvancedDraft, + catalog: AidenBotCapabilityCatalog, + show: (AidenBotDetail) -> Unit, + settled: (AidenBotDetail) -> Unit, + failed: (AidenBotChatActivityFailure) -> Unit +) { + if (!draft.changesAccess(bot, catalog)) { + show(bot) + return + } + val optimistic = draft.optimistic(bot, catalog) + host.show(optimistic) + show(optimistic) + host.launch { + try { + val saved = aidenBotHeldRequest(host.waitForConnection) { aidenBotSaveAdvanced(host.connected(), bot, draft, catalog) } + host.cache(saved) + settled(saved) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (host.cached(bot.id)?.let { it == optimistic } != false) host.show(bot) + settled(bot) + failed(aidenBotSaveFailure(error)) + } + } +} + /** Saves an Advanced draft's access and returns the fresh Bot. */ suspend fun aidenBotSaveAdvanced( client: AidenRemoteClient, @@ -191,22 +246,48 @@ fun AidenBotAdvancedSheet( bot: AidenBotDetail, client: AidenRemoteClient?, onDismiss: () -> Unit, - onSaved: (AidenBotDetail) -> Unit + onSaved: (AidenBotDetail) -> Unit, + /** Chat activity needs the Mac's `bot-activity-v1` and the phone's grant: see [aidenBotShowsChatActivity]. */ + chatActivityAvailable: Boolean = false, + /** What Chat activity saves with; the row is offered only when this is set too. */ + chatActivityHost: AidenBotChatActivityHost? = null, + /** The newest Bot after Chat activity picks: once when Advanced closes, or at once for a pick that finishes later. */ + onChatActivitySaved: (AidenBotDetail) -> Unit = {}, + /** + * Where Save runs (R15). With it, Save applies at once: [onSaved] gets the optimistic Bot and + * the sheet's caller closes it; the Mac's Bot (or, after a failure, the Bot as it was) follows + * through [onSaveSettled], and a failure is said through [onSaveFailed]. + */ + saveHost: AidenBotSaveHost? = null, + onSaveSettled: (AidenBotDetail) -> Unit = {}, + onSaveFailed: (String) -> Unit = {} ) { val palette = AidenTheme.palette val resources = LocalResources.current val scope = rememberCoroutineScope() + // A Chat activity save moves the Bot's revision; Advanced's own Save uses the newest. + var latestBot by remember(bot.id) { mutableStateOf(bot) } + var chatActivityController by remember(bot.id) { mutableStateOf(null) } + var showingChatActivity by remember(bot.id) { mutableStateOf(false) } + val currentOnChatActivitySaved by rememberUpdatedState(onChatActivitySaved) + val reporter = remember(bot.id) { AidenBotChatActivityReporter { currentOnChatActivitySaved(it) } } + // The screens behind hear about picks when Advanced goes away, however it goes. + DisposableEffect(reporter) { onDispose { reporter.close() } } + val close = { + reporter.close() + onDismiss() + } + val showsChatActivityRow = chatActivityAvailable && chatActivityHost != null var catalog by remember { mutableStateOf(null) } var draft by remember { mutableStateOf(null) } var loadError by remember { mutableStateOf(null) } var saveError by remember { mutableStateOf(null) } var isSaving by remember { mutableStateOf(false) } + // While the Mac connects, the sections show their placeholders; they fill in once it does (R15). LaunchedEffect(bot.id, client) { - val cl = client ?: run { - loadError = resources.getString(R.string.bot_advanced_connect) - return@LaunchedEffect - } + val cl = client ?: return@LaunchedEffect + loadError = null try { val cat = cl.botCapabilityCatalog(bot.id) catalog = cat @@ -219,14 +300,14 @@ fun AidenBotAdvancedSheet( } } - Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Dialog(onDismissRequest = close, properties = DialogProperties(usePlatformDefaultWidth = false)) { Scaffold( containerColor = palette.canvas, topBar = { TopAppBar( title = { Text(stringResource(R.string.bot_advanced_title), fontWeight = FontWeight.SemiBold) }, navigationIcon = { - IconButton(onClick = onDismiss) { + IconButton(onClick = close) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back), tint = palette.foreground) } }, @@ -237,13 +318,28 @@ fun AidenBotAdvancedSheet( text = if (isSaving) stringResource(R.string.action_saving) else stringResource(R.string.action_save), enabled = !isSaving && cat != null && current != null && current.isSaveable(cat), onClick = { - val cl = client ?: return@AidenPrimaryButton if (cat == null || current == null) return@AidenPrimaryButton + val host = saveHost + if (host != null) { + // The screen behind gets this Bot, which already holds any Chat activity pick. + reporter.settle() + aidenBotSaveAdvancedOptimistically( + host, latestBot, current, cat, + show = onSaved, + settled = onSaveSettled, + failed = { onSaveFailed(resources.getString(aidenBotSaveFailureText(it))) } + ) + return@AidenPrimaryButton + } + val cl = client ?: return@AidenPrimaryButton scope.launch { isSaving = true saveError = null try { - onSaved(aidenBotSaveAdvanced(cl, bot, current, cat)) + val saved = aidenBotSaveAdvanced(cl, latestBot, current, cat) + // The screen behind gets this Bot, which already holds any Chat activity pick. + reporter.settle() + onSaved(saved) } catch (error: CancellationException) { throw error } catch (_: Exception) { @@ -262,13 +358,6 @@ fun AidenBotAdvancedSheet( ) { padding -> val cat = catalog val current = draft - if (loadError == null && (cat == null || current == null)) { - AidenBotFormSkeleton( - loadingDescription = stringResource(R.string.bot_advanced_loading), - modifier = Modifier.padding(padding).aidenReadableWidth() - ) - return@Scaffold - } Column( modifier = Modifier .fillMaxSize() @@ -280,7 +369,7 @@ fun AidenBotAdvancedSheet( ) { when { loadError != null -> Text(loadError.orEmpty(), color = palette.secondary) - cat == null || current == null -> Unit + cat == null || current == null -> AidenBotAdvancedPlaceholder(stringResource(R.string.bot_advanced_loading)) else -> { AidenBotAdvancedSection(title = stringResource(R.string.bot_advanced_model)) { AidenModelPickerField( @@ -320,13 +409,71 @@ fun AidenBotAdvancedSheet( ) } } + } + } - saveError?.let { Text(it, color = palette.danger, style = MaterialTheme.typography.bodySmall) } + // Chat activity opens from the cached Bot: it doesn't wait for the catalog, a + // connection, or a clean Advanced form, so it stays reachable offline. + if (showsChatActivityRow) { + AidenConnectedColumn { + AidenGroupCard( + index = 0, + count = 1, + onClick = { + chatActivityController = chatActivityHost?.controller(latestBot) { saved -> + latestBot = saved + reporter.saved(saved) + } + showingChatActivity = true + } + ) { + Text(stringResource(R.string.bot_chat_activity_title), color = palette.foreground, modifier = Modifier.weight(1f)) + Text(aidenBotChatActivitySummary(latestBot.chatActivity), color = palette.secondary, style = MaterialTheme.typography.bodyMedium) + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = palette.secondary) + } } } + + saveError?.let { Text(it, color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall) } } } } + + val controller = chatActivityController + if (showsChatActivityRow && showingChatActivity && controller != null && chatActivityHost != null) { + // The host lets go of this page's listener when it closes (once a running save has reported). + DisposableEffect(controller) { onDispose { chatActivityHost.release(latestBot.id, controller) } } + AidenBotChatActivityDialog( + botName = latestBot.name, + controller = controller, + launch = chatActivityHost.launch, + onDismiss = { showingChatActivity = false } + ) + } +} + +/** + * Model and Access placeholders while the capability catalog arrives, sized like the real + * sections (label, picker and its "Use recommended" button; label, the access choice and its + * footer) so the page doesn't jump when they land. + */ +@Composable +private fun AidenBotAdvancedPlaceholder(loadingDescription: String) { + Column( + verticalArrangement = Arrangement.spacedBy(AidenUi.SectionGap), + modifier = Modifier.clearAndSetSemantics { contentDescription = loadingDescription } + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + AidenSkeletonBlock(width = 96.dp, height = 12.dp) + AidenSkeletonBlock(height = 56.dp, shape = MaterialTheme.shapes.large) + AidenSkeletonBlock(width = 160.dp, height = 40.dp, shape = MaterialTheme.shapes.large) + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + AidenSkeletonBlock(width = 96.dp, height = 12.dp) + AidenSkeletonBlock(height = 48.dp, shape = MaterialTheme.shapes.extraLarge) + AidenSkeletonBlock(height = 32.dp) + } + } } @Composable diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCanonicalAvatarView.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCanonicalAvatarView.kt index e2fa3399d..b8a91a56b 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCanonicalAvatarView.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCanonicalAvatarView.kt @@ -4,10 +4,12 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.asImageBitmap @@ -16,11 +18,15 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import sbtbiswas.AidenOnTheGo.features.chat.aidenPowerSaveMode import sbtbiswas.AidenOnTheGo.features.remote.AidenRemoteCoordinator +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMark +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMarkKind import sbtbiswas.AidenOnTheGo.models.AidenBotAvatar import sbtbiswas.AidenOnTheGo.networking.AidenRemoteClient import sbtbiswas.AidenOnTheGo.persistence.AidenBotCache import sbtbiswas.AidenOnTheGo.ui.theme.AidenMotion +import sbtbiswas.AidenOnTheGo.ui.theme.AidenTheme import sbtbiswas.AidenOnTheGo.ui.theme.aidenReduceMotion import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.res.stringResource @@ -44,6 +50,8 @@ fun AidenBotCanonicalAvatarView( botCache: AidenBotCache? = null, name: String = "", size: Dp = 48.dp, + /** The Bot is working: its face glances, or a glance badge says so (R8, R19). */ + working: Boolean = false, modifier: Modifier = Modifier ) { val coordinatorClient = coordinator?.client?.collectAsStateWithLifecycle()?.value @@ -90,16 +98,41 @@ fun AidenBotCanonicalAvatarView( } val reduceMotion = aidenReduceMotion() + val motion = aidenBotFaceMotion( + working = working, + photo = customBitmap != null, + reduceMotion = reduceMotion, + powerSave = working && aidenPowerSaveMode(), + resumed = aidenBotIsResumed() + ) Box(modifier = modifier.size(size)) { if (reduceMotion) { - AidenBotAvatarLayer(customBitmap, avatar, name, size) + AidenBotAvatarLayer(customBitmap, avatar, name, size, working) } else { Crossfade( targetState = customBitmap, animationSpec = AidenMotion.short(), label = "bot_avatar_reveal" ) { bmp -> - AidenBotAvatarLayer(bmp, avatar, name, size) + AidenBotAvatarLayer(bmp, avatar, name, size, working) + } + } + // A photo can't glance, and a still face can't show it's working: a small glance mark does. + if (motion.badge) { + Box( + Modifier + .align(Alignment.BottomEnd) + .size(size * 0.38f) + .clip(CircleShape) + .background(AidenTheme.palette.canvas), + contentAlignment = Alignment.Center + ) { + AidenActivityMark( + mark = AidenActivityMarkKind.GLANCE, + size = size * 0.3f, + active = motion.badgeAnimates, + color = AidenTheme.palette.foreground + ) } } } @@ -110,7 +143,8 @@ private fun AidenBotAvatarLayer( bitmap: Bitmap?, avatar: AidenBotAvatar, name: String, - size: Dp + size: Dp, + working: Boolean ) { if (bitmap != null) { Image( @@ -125,7 +159,8 @@ private fun AidenBotAvatarLayer( AidenBotSemanticAvatarView( avatar = avatar.semantic, name = name, - size = size + size = size, + working = working ) } } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt new file mode 100644 index 000000000..7f134e48b --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt @@ -0,0 +1,380 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import sbtbiswas.AidenOnTheGo.R +import sbtbiswas.AidenOnTheGo.models.AidenBotChatActivity +import sbtbiswas.AidenOnTheGo.models.AidenBotChatActivityLevel +import sbtbiswas.AidenOnTheGo.models.AidenBotChatActivityWording +import sbtbiswas.AidenOnTheGo.models.AidenBotDetail +import sbtbiswas.AidenOnTheGo.models.AidenBotIdentityPatch +import sbtbiswas.AidenOnTheGo.models.AidenServer +import sbtbiswas.AidenOnTheGo.networking.AidenRemoteClient +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteClientException +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteErrorCode + +// Advanced → Chat activity (spec §7, contract revision 30): the model behind the page. +// The screen itself is `AidenBotChatActivityScreen.kt`; the looping preview is `AidenBotChatActivityPreviewCard.kt`. + +@get:StringRes +val AidenBotChatActivityLevel.label: Int + get() = when (this) { + AidenBotChatActivityLevel.QUIET -> R.string.bot_chat_activity_level_quiet + AidenBotChatActivityLevel.SUMMARY -> R.string.bot_chat_activity_level_summary + AidenBotChatActivityLevel.DETAILED -> R.string.bot_chat_activity_level_detailed + } + +@get:StringRes +val AidenBotChatActivityLevel.detail: Int + get() = when (this) { + AidenBotChatActivityLevel.QUIET -> R.string.bot_chat_activity_level_quiet_detail + AidenBotChatActivityLevel.SUMMARY -> R.string.bot_chat_activity_level_summary_detail + AidenBotChatActivityLevel.DETAILED -> R.string.bot_chat_activity_level_detailed_detail + } + +@get:StringRes +val AidenBotChatActivityWording.label: Int + get() = when (this) { + AidenBotChatActivityWording.FOLLOWS -> R.string.bot_chat_activity_wording_follows + AidenBotChatActivityWording.THINKING -> R.string.bot_chat_activity_wording_thinking + } + +@get:StringRes +val AidenBotChatActivityWording.detail: Int + get() = when (this) { + AidenBotChatActivityWording.FOLLOWS -> R.string.bot_chat_activity_wording_follows_detail + AidenBotChatActivityWording.THINKING -> R.string.bot_chat_activity_wording_thinking_detail + } + +/** Advanced's trailing value: "Quiet · Follows the work". */ +@Composable +fun aidenBotChatActivitySummary(value: AidenBotChatActivity?): String { + val resolved = value ?: AidenBotChatActivity.DEFAULT + return stringResource(R.string.bot_chat_activity_value, stringResource(resolved.level.label), stringResource(resolved.wording.label)) +} + +/** + * Whether the Mac offers Bot activity (`bot-activity-v1`) and this phone holds `bot:activity`. + * Before `/server` has answered (a cold start, or offline), what the pairing last saw stands in + * (R21): the feature token `/server` announced and the grant it holds, both persisted. Once + * `/server` answers it decides. + */ +fun aidenBotActivitySupported(server: AidenServer?, cachedSupport: Boolean): Boolean = + server?.supportsBotActivity ?: cachedSupport + +/** + * Advanced offers Chat activity on a Mac that announces `bot-activity-v1` to a phone that + * holds `bot:activity`, once a Bot's detail (cached or fresh) is on screen. + */ +fun aidenBotShowsChatActivity(server: AidenServer?, hasBaseline: Boolean, cachedSupport: Boolean = false): Boolean = + hasBaseline && aidenBotActivitySupported(server, cachedSupport) + +// --- The previews' script: what each level shows on the Mac --- + +/** One frame of the scripted turn, as a level shows it on the Mac. */ +data class AidenBotChatActivityPreviewFrame( + /** The status line's phase id, or null on the reply frame. */ + val phase: String?, + /** Finished step rows, as string ids: Detailed while live, or the open recap after the reply. */ + val steps: List, + /** The step in progress, listed while the line says its phase (Detailed only). */ + @param:StringRes val running: Int?, + val recapShown: Boolean, + val recapOpen: Boolean +) + +/** Think, read, search, edit, reply: the desktop page's script and timings. */ +object AidenBotChatActivityPreview { + val FRAME_MILLIS = longArrayOf(1_400, 1_600, 1_600, 1_400, 2_800) + + /** The reply frame: the last one. */ + val REPLY_FRAME: Int get() = FRAME_MILLIS.size - 1 + + /** Frames that show the line mid-turn; the reply is [REPLY_FRAME]. */ + private const val MID_TURN_FRAME = 2 + + private val PHASES = listOf("thinking", "reading", "searching", "editing", null) + private val STEPS = listOf( + R.string.bot_chat_activity_preview_step_read, + R.string.bot_chat_activity_preview_step_search, + R.string.bot_chat_activity_preview_step_edit + ) + private val RUNNING = listOf( + R.string.bot_activity_reading, + R.string.bot_activity_searching, + R.string.bot_activity_editing + ) + + /** + * The frame a preview holds when it doesn't move (Reduce Motion). Summary's whole point is + * the recap above the reply, so it holds the reply; Quiet and Detailed hold the line + * (and Detailed's steps) mid-turn. + */ + fun stillFrame(level: AidenBotChatActivityLevel): Int = + if (level == AidenBotChatActivityLevel.SUMMARY) REPLY_FRAME else MID_TURN_FRAME + + fun frameIndex(elapsedMs: Long): Int { + val total = FRAME_MILLIS.sum() + var time = ((elapsedMs % total) + total) % total + FRAME_MILLIS.forEachIndexed { index, duration -> + if (time < duration) return index + time -= duration + } + return REPLY_FRAME + } + + fun frame(index: Int, level: AidenBotChatActivityLevel): AidenBotChatActivityPreviewFrame { + val clamped = index.coerceIn(0, PHASES.size - 1) + val phase = PHASES[clamped] + if (phase != null) { + val live = level == AidenBotChatActivityLevel.DETAILED && clamped >= 1 + return AidenBotChatActivityPreviewFrame( + phase = phase, + steps = if (live) STEPS.take(clamped - 1) else emptyList(), + running = if (live) RUNNING[clamped - 1] else null, + recapShown = false, + recapOpen = false + ) + } + return when (level) { + AidenBotChatActivityLevel.QUIET -> AidenBotChatActivityPreviewFrame(null, emptyList(), null, recapShown = false, recapOpen = false) + AidenBotChatActivityLevel.SUMMARY -> AidenBotChatActivityPreviewFrame(null, emptyList(), null, recapShown = true, recapOpen = false) + AidenBotChatActivityLevel.DETAILED -> AidenBotChatActivityPreviewFrame(null, STEPS, null, recapShown = true, recapOpen = true) + } + } + + /** Whether a preview advances: never under Reduce Motion, and only while it is in front and visible. */ + fun plays(reduceMotion: Boolean, resumed: Boolean, onScreen: Boolean): Boolean = !reduceMotion && resumed && onScreen + + /** + * The frame to show. Reduce Motion holds [stillFrame]; a preview that is paused (app in + * the background, scrolled out of view) keeps the frame it had; otherwise the shared + * clock decides, so every preview on the page is on the same step of the turn. + */ + fun shownFrame(level: AidenBotChatActivityLevel, reduceMotion: Boolean, playing: Boolean, current: Int, nowMs: Long): Int = when { + reduceMotion -> stillFrame(level) + playing -> frameIndex(nowMs) + else -> current + } +} + +// --- The controller: picks apply at once and save in the background --- + +/** Why a pick didn't stick, so the page can say so in the right words. */ +enum class AidenBotChatActivityFailure { + /** The Mac refused or failed the save. */ + GENERIC, + /** The phone couldn't reach the Mac: offline, disconnected, or too slow. */ + OFFLINE, + /** The Bot changed on the Mac; the page now shows what the Mac holds. */ + CONFLICT +} + +data class AidenBotChatActivityUiState( + /** What the page shows: the last pick, saved or not. */ + val value: AidenBotChatActivity, + /** The last choice the Mac confirmed. A failed save returns [value] to this. */ + val saved: AidenBotChatActivity, + /** The revision the next save goes out on. */ + val revision: String, + val isSaving: Boolean = false, + /** Set when the last pick didn't stick; cleared by the next pick or [AidenBotChatActivityController.dismissError]. */ + val saveFailure: AidenBotChatActivityFailure? = null +) { + val saveFailed: Boolean get() = saveFailure != null +} + +/** + * A save that outran its limit; the pick returns to the saved choice. It may have reached the + * Mac, so it counts as a transport failure everywhere (a Create replays its key after one). + */ +class AidenBotChatActivityTimeout : java.io.IOException("The save took too long.") + +/** The Mac stayed unreachable for the whole hold. */ +class AidenBotChatActivityUnreachable : Exception("The Mac could not be reached.") + +/** The save may or may not have reached the Mac: no answer came. A late success is possible. */ +fun aidenBotChatActivityTransportFailure(error: Throwable): Boolean = + error is AidenBotChatActivityTimeout || error is java.io.IOException + +/** The save never got an answer from the Mac: offline, disconnected, or too slow. */ +fun aidenBotChatActivityMacUnreachable(error: Throwable): Boolean = + aidenBotChatActivityTransportFailure(error) || error is AidenBotChatActivityUnreachable || + error is AidenRemoteClientException.Disconnected + +/** The Bot changed on the Mac since this phone read it (a stale `If-Match`). */ +fun aidenBotIsRevisionConflict(error: Throwable): Boolean = + (error as? AidenRemoteClientException.Server)?.let { it.statusCode == 409 || it.body.code == AidenRemoteErrorCode.REVISION_CONFLICT } == true + +/** + * Applies each pick at once and saves it with no Save button, like the Mac page. + * + * - A failed save returns to the last choice the Mac confirmed ([AidenBotChatActivityUiState.saved]). + * - A pick made while a save is in flight shows at once and goes out next (the last pick wins), + * on the revision that save returned. Picking back to the saved value is not special: the Mac + * ends on whatever was picked last. + * - A stale revision, a timeout or a transport failure refreshes the Bot and adopts the Mac's + * revision and choice, tells [onSaved], and rolls back to what the Mac holds. A save that + * landed late therefore shows as saved, with no error. + * - A request that has gone out finishes, and reports through [onSaved], even if the caller + * leaves meanwhile: the Mac took it, so the screens behind must hear. Leaving only stops the + * picks queued behind it. + * - While [waitForConnection] says the Mac is still connecting, a pick is held for up to + * [connectionHoldMillis], then sent or rolled back. Nothing is queued durably. + * - Each request is bounded by [requestLimitMillis]: the phone's HTTP stack waits for + * connectivity much longer than a settings pick should. + * + * Confined to one thread (the UI's): [pick] suspends only inside a save, and the first + * caller drives later picks, so later callers return at once. + */ +class AidenBotChatActivityController( + bot: AidenBotDetail, + private val save: suspend (AidenBotChatActivity, String) -> AidenBotDetail, + private val refresh: (suspend () -> AidenBotDetail)? = null, + private val onSaved: (AidenBotDetail) -> Unit = {}, + private val waitForConnection: (suspend () -> Boolean)? = null, + private val connectionHoldMillis: Long = CONNECTION_HOLD_MILLIS, + private val requestLimitMillis: Long = REQUEST_LIMIT_MILLIS, + /** The saves a pick started have all settled (reported or rolled back). */ + private val onSettled: () -> Unit = {} +) { + private val _state = MutableStateFlow( + (bot.chatActivity ?: AidenBotChatActivity.DEFAULT).let { + AidenBotChatActivityUiState(value = it, saved = it, revision = bot.revision) + } + ) + val state: StateFlow = _state.asStateFlow() + + /** The newest pick made while a save was in flight. */ + private var pending: AidenBotChatActivity? = null + + suspend fun pick(next: AidenBotChatActivity) { + val current = _state.value + if (next == current.value) return + _state.value = current.copy(value = next, saveFailure = null) + if (current.isSaving) { + pending = next + return + } + _state.update { it.copy(isSaving = true) } + try { + saveUntilSettled(next) + } finally { + pending = null + _state.update { it.copy(isSaving = false, value = it.saved) } + onSettled() + } + } + + fun dismissError() { + _state.update { it.copy(saveFailure = null) } + } + + private suspend fun saveUntilSettled(first: AidenBotChatActivity) { + var target = first + while (true) { + // The request, and the report that follows it, are not cancelled with the caller. + // A caller that left stops here, so the picks queued behind the request aren't sent. + val saved = withContext(NonCancellable) { send(target) } + currentCoroutineContext().ensureActive() + if (!saved) return + val following = pending ?: return + pending = null + if (following == _state.value.saved) return + target = following + } + } + + /** One save: true when the Mac holds [target] afterwards; false when the pick didn't stick. */ + private suspend fun send(target: AidenBotChatActivity): Boolean { + val detail = try { + holdForConnection() + val revision = _state.value.revision + withTimeoutOrNull(requestLimitMillis) { save(target, revision) } ?: throw AidenBotChatActivityTimeout() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + val refreshes = aidenBotIsRevisionConflict(error) || aidenBotChatActivityTransportFailure(error) + val before = _state.value.saved + val fresh = if (refreshes) adoptTheMacsChoice() else null + val macHolds = fresh?.let { it.chatActivity ?: AidenBotChatActivity.DEFAULT } + if (macHolds == target) return true + val failure = when { + aidenBotIsRevisionConflict(error) -> AidenBotChatActivityFailure.CONFLICT + // The answer never came, but the Mac now holds something nobody here picked. + macHolds != null && macHolds != before -> AidenBotChatActivityFailure.CONFLICT + aidenBotChatActivityMacUnreachable(error) -> AidenBotChatActivityFailure.OFFLINE + else -> AidenBotChatActivityFailure.GENERIC + } + _state.update { it.copy(value = it.saved, saveFailure = failure) } + return false + } + _state.update { it.copy(revision = detail.revision, saved = detail.chatActivity ?: target) } + onSaved(detail) + return true + } + + private suspend fun holdForConnection() { + val wait = waitForConnection ?: return + if (withTimeoutOrNull(connectionHoldMillis) { wait() } != true) throw AidenBotChatActivityUnreachable() + } + + /** The Mac may hold something else now: take its revision and choice, so the next pick can save. */ + private suspend fun adoptTheMacsChoice(): AidenBotDetail? { + val refresh = refresh ?: return null + val fresh = try { + withTimeoutOrNull(requestLimitMillis) { refresh() } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + null + } ?: return null + _state.update { it.copy(revision = fresh.revision, saved = fresh.chatActivity ?: AidenBotChatActivity.DEFAULT) } + onSaved(fresh) + return fresh + } + + companion object { + const val CONNECTION_HOLD_MILLIS = 8_000L + const val REQUEST_LIMIT_MILLIS = 12_000L + } +} + +/** + * A controller that saves through the Mac. [client] is read at each request, so a pick made + * before the connection is up still goes out on the client that exists by then. + * + * The patch is sent with `supportsBotActivity = true`: the page is offered only to a phone + * that holds `bot:activity` on a Mac that announces the feature, and the client refuses a + * `chatActivity` patch otherwise. + */ +fun aidenBotChatActivityController( + bot: AidenBotDetail, + client: () -> AidenRemoteClient?, + onSaved: (AidenBotDetail) -> Unit = {}, + waitForConnection: (suspend () -> Boolean)? = null, + onSettled: () -> Unit = {} +): AidenBotChatActivityController { + fun connected(): AidenRemoteClient = client() ?: throw AidenRemoteClientException.Disconnected() + return AidenBotChatActivityController( + bot, + save = { next, revision -> + connected().updateBotIdentity(bot.id, revision, AidenBotIdentityPatch(chatActivity = next), supportsBotActivity = true) + }, + refresh = { connected().bot(bot.id) }, + onSaved = onSaved, + waitForConnection = waitForConnection, + onSettled = onSettled + ) +} diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityPreviewCard.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityPreviewCard.kt new file mode 100644 index 000000000..f0ff1d964 --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityPreviewCard.kt @@ -0,0 +1,136 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import sbtbiswas.AidenOnTheGo.R +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMark +import sbtbiswas.AidenOnTheGo.models.AidenBotChatActivityLevel +import sbtbiswas.AidenOnTheGo.models.AidenBotChatActivityWording +import sbtbiswas.AidenOnTheGo.ui.theme.AidenTheme +import sbtbiswas.AidenOnTheGo.ui.theme.aidenReduceMotion + +/** How often a playing preview checks the shared clock. */ +private const val PREVIEW_TICK_MILLIS = 200L + +/** + * A small chat replaying one scripted turn, as [level] shows it on the Mac. + * + * Every preview reads the same wall clock, so the ones on a page are on the same step of the + * turn. It holds still, with no Play control, under Reduce Motion (system or in-app) on the + * level's still frame; it pauses while the app isn't in front or the preview is scrolled out + * of view. The marks pause with it. + * + * Pass [description] for a standalone preview. Left null, the preview is decoration hidden + * from TalkBack: the choice row it sits in already speaks the title and detail. + */ +@Composable +fun AidenBotChatActivityPreviewCard( + level: AidenBotChatActivityLevel, + wording: AidenBotChatActivityWording, + modifier: Modifier = Modifier, + description: String? = null +) { + val palette = AidenTheme.palette + val reduceMotion = aidenReduceMotion() + val resumed = aidenBotIsResumed() + var onScreen by remember { mutableStateOf(true) } + val playing = AidenBotChatActivityPreview.plays(reduceMotion, resumed, onScreen) + var index by remember { mutableIntStateOf(AidenBotChatActivityPreview.stillFrame(level)) } + LaunchedEffect(level, reduceMotion, playing) { + index = AidenBotChatActivityPreview.shownFrame(level, reduceMotion, playing, index, System.currentTimeMillis()) + while (playing) { + delay(PREVIEW_TICK_MILLIS) + index = AidenBotChatActivityPreview.shownFrame(level, reduceMotion, playing, index, System.currentTimeMillis()) + } + } + val frame = AidenBotChatActivityPreview.frame(index, level) + val markSize = with(LocalDensity.current) { 14.sp.toDp() } + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + onScreen = coordinates.isAttached && coordinates.findRootCoordinates().boundsInWindow().overlaps(coordinates.boundsInWindow()) + } + .clip(MaterialTheme.shapes.medium) + .background(palette.canvas) + .padding(10.dp) + .clearAndSetSemantics { if (description != null) contentDescription = description } + ) { + Text( + stringResource(R.string.bot_chat_activity_preview_prompt), + style = MaterialTheme.typography.labelMedium, + color = palette.foreground, + modifier = Modifier + .align(Alignment.End) + .clip(MaterialTheme.shapes.small) + .background(palette.raised) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) + if (frame.recapShown) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (frame.recapOpen) Icons.Filled.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = palette.secondary, + modifier = Modifier.size(markSize) + ) + Text(stringResource(R.string.bot_chat_activity_preview_recap), style = MaterialTheme.typography.labelSmall, color = palette.secondary) + } + } + frame.steps.forEach { step -> + Text(stringResource(step), style = MaterialTheme.typography.labelSmall, color = palette.secondary, modifier = Modifier.padding(start = 14.dp)) + } + frame.running?.let { running -> + Text( + stringResource(running), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = palette.foreground, + modifier = Modifier.padding(start = 14.dp) + ) + } + val phase = frame.phase + if (phase != null) { + val shown = aidenBotActivityDisplay(phase, wording, null) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + AidenActivityMark(mark = shown.mark, size = markSize, active = playing, color = palette.secondary) + Text(shown.word(), style = MaterialTheme.typography.labelMedium, color = palette.secondary) + } + } else { + Text(stringResource(R.string.bot_chat_activity_preview_reply), style = MaterialTheme.typography.labelMedium, color = palette.foreground) + } + } +} diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt new file mode 100644 index 000000000..454d50775 --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt @@ -0,0 +1,339 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import sbtbiswas.AidenOnTheGo.R +import sbtbiswas.AidenOnTheGo.features.remote.AidenConnectionState +import sbtbiswas.AidenOnTheGo.features.remote.AidenRemoteCoordinator +import sbtbiswas.AidenOnTheGo.models.AidenBotChatActivityLevel +import sbtbiswas.AidenOnTheGo.models.AidenBotChatActivityWording +import sbtbiswas.AidenOnTheGo.models.AidenBotDetail +import sbtbiswas.AidenOnTheGo.networking.AidenRemoteClient +import sbtbiswas.AidenOnTheGo.ui.theme.AidenConnectedColumn +import sbtbiswas.AidenOnTheGo.ui.theme.AidenGroupCard +import sbtbiswas.AidenOnTheGo.ui.theme.AidenTheme +import sbtbiswas.AidenOnTheGo.ui.theme.AidenUi +import sbtbiswas.AidenOnTheGo.ui.theme.aidenReadableWidth + +// Advanced → Chat activity (spec §7): the page. The model and the previews' script live in +// `AidenBotChatActivityModel.kt`; the looping preview in `AidenBotChatActivityPreviewCard.kt`. + +/** + * What Chat activity needs from its surroundings, bound to one pairing and shared by every + * Advanced sheet opened for it. + * + * A pick is saved from [launch]'s scope, which outlives the page (and Advanced, and the screen + * behind it), so leaving mid-save doesn't cancel the request. [cache] is the one place a + * confirmed pick is written to the Bot cache, so a pick that finishes after the person has gone + * (or after the pairing changed, which [cache] checks) never depends on a view. + */ +class AidenBotChatActivityHost( + /** The pairing's client now, or null once that pairing is gone or the phone is offline. */ + val client: () -> AidenRemoteClient?, + /** Suspends until the Mac is connected; false when it can't be (unpaired, another pairing). */ + val waitForConnection: suspend () -> Boolean, + val launch: (suspend () -> Unit) -> Unit, + val cache: (AidenBotDetail) -> Unit +) { + private class Live(val controller: AidenBotChatActivityController, var listener: (AidenBotDetail) -> Unit) { + /** The page that set [listener] is gone. */ + var released = false + } + + private val live = mutableMapOf() + + /** How many Bots have a controller (and its page's listener) held here. */ + internal val heldCount: Int get() = live.size + + /** + * The controller for [bot]. One still saving is reused, so a sheet reopened mid-save never + * forks a second writer on a stale revision; [onSaved] then goes to the newest sheet. + */ + fun controller(bot: AidenBotDetail, onSaved: (AidenBotDetail) -> Unit): AidenBotChatActivityController { + live[bot.id]?.takeIf { it.controller.state.value.isSaving }?.let { + it.listener = onSaved + it.released = false + return it.controller + } + lateinit var entry: Live + val controller = aidenBotChatActivityController( + bot, + client = client, + onSaved = { saved -> + cache(saved) + entry.listener(saved) + }, + waitForConnection = waitForConnection, + // A page that left mid-save heard its last answer: let go of it now. + onSettled = { if (entry.released) forget(bot.id, entry) } + ) + entry = Live(controller, onSaved) + live[bot.id] = entry + return controller + } + + /** + * The page showing [controller] went away. Its listener goes now, or, while a save is still + * running, once that save has reported (so the screens behind still hear it). + */ + fun release(botId: String, controller: AidenBotChatActivityController) { + val entry = live[botId]?.takeIf { it.controller === controller } ?: return + entry.released = true + if (!controller.state.value.isSaving) forget(botId, entry) + } + + private fun forget(botId: String, entry: Live) { + if (live[botId] === entry) live.remove(botId) + entry.listener = {} + } +} + +/** + * Waits for the Mac: true once [connection] is connected and a [client] exists, false as soon as + * the phone needs pairing (nothing to wait for). Offline and connecting both keep waiting; the + * controller bounds the wait. + */ +suspend fun aidenBotAwaitConnected(connection: Flow, client: Flow): Boolean = + combine(connection, client) { state, current -> state to current } + .first { (state, current) -> state == AidenConnectionState.NEEDS_PAIRING || (state == AidenConnectionState.CONNECTED && current != null) } + .first == AidenConnectionState.CONNECTED + +/** + * The host for the pairing that is active now; a pick never reaches another pairing's Mac. It is + * pinned to the pairing itself, not the Mac's id, so an unpair and re-pair of the same Mac is + * another pairing. + */ +fun AidenRemoteCoordinator.botChatActivityHost(): AidenBotChatActivityHost = pairingMemo("bot-chat-activity-host") { + val pairing = activePairing + fun samePairing() = holdsPairing(pairing) + AidenBotChatActivityHost( + client = { pairedClient(pairing) }, + waitForConnection = { samePairing() && aidenBotAwaitConnected(connectionState, client.map { pairedClient(pairing) }) && samePairing() }, + launch = { block -> launchDetached(block) }, + cache = pinnedBotCache() + ) +} + +/** + * Tells the screens behind Advanced about Chat activity picks once: when Advanced closes, with + * the newest saved Bot. A pick that finishes after that is reported at once. Advanced itself + * keeps its baseline current on every pick, so this is only about the screens it covers. + */ +class AidenBotChatActivityReporter(private val report: (AidenBotDetail) -> Unit) { + private var pending: AidenBotDetail? = null + private var closed = false + + fun saved(detail: AidenBotDetail) { + if (closed) report(detail) else pending = detail + } + + /** Advanced is going away: report what was saved while it was open. Safe to call twice. */ + fun close() { + closed = true + val detail = pending ?: return + pending = null + report(detail) + } + + /** The screen behind already has the newest Bot (Advanced's own Save returned it). */ + fun settle() { + pending = null + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AidenBotChatActivityDialog( + botName: String, + controller: AidenBotChatActivityController, + launch: (suspend () -> Unit) -> Unit, + onDismiss: () -> Unit +) { + val palette = AidenTheme.palette + val state by controller.state.collectAsState() + // Leaving the page leaves the error behind; a save still running carries on without it. + DisposableEffect(controller) { onDispose { controller.dismissError() } } + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Scaffold( + containerColor = palette.canvas, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.bot_chat_activity_title), fontWeight = FontWeight.SemiBold) }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back), tint = palette.foreground) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = palette.canvas, titleContentColor = palette.foreground) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .aidenReadableWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = AidenUi.ScreenGutter, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(AidenUi.SectionGap) + ) { + Text(stringResource(R.string.bot_chat_activity_description, botName), color = palette.secondary, style = MaterialTheme.typography.bodyMedium) + state.saveFailure?.let { failure -> + Text( + stringResource( + when (failure) { + AidenBotChatActivityFailure.OFFLINE -> R.string.bot_chat_activity_save_unreachable + AidenBotChatActivityFailure.CONFLICT -> R.string.bot_chat_activity_save_conflict + AidenBotChatActivityFailure.GENERIC -> R.string.bot_chat_activity_save_failed + } + ), + color = aidenBotErrorTextColor(), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite } + ) + } + AidenBotChatActivityGroup(stringResource(R.string.bot_chat_activity_level_title)) { + val levels = AidenBotChatActivityLevel.entries + levels.forEachIndexed { index, level -> + AidenBotChatActivityChoice( + index = index, + count = levels.size, + title = stringResource(level.label), + detail = stringResource(level.detail), + recommended = level == AidenBotChatActivityLevel.QUIET, + selected = state.value.level == level, + onPick = { launch { controller.pick(state.value.copy(level = level)) } } + ) { AidenBotChatActivityPreviewCard(level = level, wording = state.value.wording) } + } + } + AidenBotChatActivityGroup(stringResource(R.string.bot_chat_activity_wording_title)) { + val wordings = AidenBotChatActivityWording.entries + wordings.forEachIndexed { index, wording -> + AidenBotChatActivityChoice( + index = index, + count = wordings.size, + title = stringResource(wording.label), + detail = stringResource(wording.detail), + recommended = false, + selected = state.value.wording == wording, + onPick = { launch { controller.pick(state.value.copy(wording = wording)) } } + ) { AidenBotChatActivityPreviewCard(level = AidenBotChatActivityLevel.QUIET, wording = wording) } + } + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.bot_chat_activity_phones_title), + style = MaterialTheme.typography.labelLarge, + color = palette.secondary, + modifier = Modifier.padding(horizontal = 4.dp).semantics { heading() } + ) + Text( + stringResource(R.string.bot_chat_activity_phones_description), + color = palette.secondary, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 4.dp) + ) + AidenBotChatActivityPreviewCard( + level = AidenBotChatActivityLevel.QUIET, + wording = state.value.wording, + description = stringResource(R.string.bot_chat_activity_preview) + ) + } + } + } + } +} + +@Composable +private fun AidenBotChatActivityGroup(title: String, content: @Composable ColumnScope.() -> Unit) { + val palette = AidenTheme.palette + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + title, + style = MaterialTheme.typography.labelLarge, + color = palette.secondary, + modifier = Modifier.padding(horizontal = 4.dp).semantics { heading() } + ) + AidenConnectedColumn(modifier = Modifier.selectableGroup(), content = content) + } +} + +/** + * A radio row: the radio and the tonal fill show the choice; there is no border. It is never + * disabled while a save runs, because a pick applies at once. + */ +@Composable +private fun AidenBotChatActivityChoice( + index: Int, + count: Int, + title: String, + detail: String, + recommended: Boolean, + selected: Boolean, + onPick: () -> Unit, + preview: @Composable () -> Unit +) { + val palette = AidenTheme.palette + AidenGroupCard( + index = index, + count = count, + modifier = Modifier.semantics { this.selected = selected }, + selected = selected, + role = Role.RadioButton, + onClick = onPick, + verticalAlignment = Alignment.Top + ) { + RadioButton(selected = selected, onClick = null, colors = RadioButtonDefaults.colors(selectedColor = palette.accent)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, color = palette.foreground, style = MaterialTheme.typography.bodyLarge) + if (recommended) { + Text(stringResource(R.string.bot_chat_activity_recommended), color = palette.secondary, style = MaterialTheme.typography.labelSmall) + } + } + Text(detail, color = palette.secondary, style = MaterialTheme.typography.bodySmall) + preview() + } + } +} diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatHeader.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatHeader.kt index e1f47f146..d016d5b39 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatHeader.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatHeader.kt @@ -194,7 +194,7 @@ fun AidenBotChatTopBar( if (canDelete) { HorizontalDivider() DropdownMenuItem( - text = { Text(stringResource(R.string.action_delete), color = palette.danger) }, + text = { Text(stringResource(R.string.action_delete), color = aidenBotErrorTextColor()) }, leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null, tint = palette.danger) }, onClick = { menuOpen = false diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt index 7e205fbb1..cf22340e4 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt @@ -1,5 +1,6 @@ package sbtbiswas.AidenOnTheGo.features.bots +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -10,14 +11,24 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import sbtbiswas.AidenOnTheGo.ui.theme.AidenSkeletonBlock import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.annotation.StringRes import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.launch +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import sbtbiswas.AidenOnTheGo.R import sbtbiswas.AidenOnTheGo.features.scheduled.aidenScheduledRunFailureIsAmbiguous import sbtbiswas.AidenOnTheGo.features.remote.AidenRemoteCoordinator @@ -87,7 +98,9 @@ data class AidenBotCreateDraft( class AidenBotCreateAttempt internal constructor( val draft: AidenBotCreateDraft, val request: AidenBotCreateRequest, - val key: UUID + val key: UUID, + /** When it first went out: the Mac keeps an idempotency key for a day. */ + val createdAtMs: Long ) /** @@ -96,29 +109,346 @@ class AidenBotCreateAttempt internal constructor( * person's draft is unchanged, even if the Mac's catalog has since moved or dropped the * model. A changed draft, or any attempt after a definite refusal, builds a new request * with a new key: the Mac keeps a refused key, replays its refusal for the same body and - * answers a different body with `idempotency_conflict`. + * answers a different body with `idempotency_conflict`. An uncertain attempt replays only + * while the Mac still keeps its key ([aidenBotCreateReplayable]); after that the same answers + * are a new request, sent only by the person's own Create. */ -class AidenBotCreateAttempts(private val makeKey: () -> UUID = UUID::randomUUID) { +class AidenBotCreateAttempts( + private val nowMs: () -> Long = System::currentTimeMillis, + private val makeKey: () -> UUID = UUID::randomUUID +) { private var uncertain: AidenBotCreateAttempt? = null - /** True when the last Create failed uncertainly and [draft] is unchanged, so Create replays it. */ - fun replays(draft: AidenBotCreateDraft): Boolean = uncertain?.draft == draft + /** True while an attempt that may have reached the Mac waits to be replayed. */ + val holdsUncertain: Boolean get() = uncertain != null + + /** True when the last Create failed uncertainly, [draft] is unchanged and the Mac still keeps its key, so Create replays it. */ + fun replays(draft: AidenBotCreateDraft): Boolean = replay(draft) != null + + /** The uncertain attempt for [draft] while the Mac still keeps its key, or null; never a new one. */ + fun replay(draft: AidenBotCreateDraft): AidenBotCreateAttempt? = + uncertain?.takeIf { it.draft == draft && aidenBotCreateReplayable(it.createdAtMs, nowMs()) } + + /** True when [attempt] is the one that may have reached the Mac, however long ago. */ + fun mayHaveArrived(attempt: AidenBotCreateAttempt): Boolean = uncertain === attempt /** * The attempt to send for [draft], or null when no request can be built from it. An - * uncertain attempt for the same draft comes back unchanged; [catalog] only shapes a new one. + * uncertain attempt for the same draft comes back unchanged while the Mac keeps its key; + * [catalog] only shapes a new one. */ fun begin(draft: AidenBotCreateDraft, catalog: AidenBotCapabilityCatalog?): AidenBotCreateAttempt? { - uncertain?.takeIf { it.draft == draft }?.let { return it } + replay(draft)?.let { return it } uncertain = null val request = draft.request(catalog) ?: return null - return AidenBotCreateAttempt(draft, request, makeKey()) + return AidenBotCreateAttempt(draft, request, makeKey(), nowMs()) + } + + /** An earlier launch's attempt that may have reached the Mac: the same answers replay it. */ + fun adopt(attempt: AidenBotCreateAttempt) { + uncertain = attempt } - /** Records how [attempt] ended: only an uncertain failure stays replayable. */ + /** Nothing to replay any more: access ended, or the Mac's list shows the Bot. */ + fun forget() { + uncertain = null + } + + /** + * Records how [attempt] ended: only an uncertain failure stays replayable. A try that never + * reached the Mac (it stayed unreachable) changes nothing, so a try before it that may have + * made the Bot is still the one replayed. + */ fun failed(attempt: AidenBotCreateAttempt, error: Throwable) { + if (error is AidenBotChatActivityUnreachable) return uncertain = if (aidenScheduledRunFailureIsAmbiguous(error)) attempt else null } + + /** The Mac made [attempt]'s Bot: another Create with the same answers is another Bot. */ + fun succeeded(attempt: AidenBotCreateAttempt) { + if (uncertain === attempt) uncertain = null + } +} + +/** Where one pairing's New Bot on its way is kept across launches (beside its chats' outboxes). */ +class AidenBotCreateStore( + val load: suspend () -> AidenBotPendingCreate?, + /** Queues the write at once; the answer is true once it is on disk, false when it couldn't be kept. */ + val save: (AidenBotPendingCreate) -> Deferred, + val remove: () -> Unit +) + +/** + * Bot creation that never waits on New Bot (R15, as on iOS). Create closes the form at once and + * Bots home shows the Bot as on its way ([pending]); it goes out once the Mac is connected (up + * to [holdMillis]) and within [limitMillis]. A failure takes the row away and leaves a + * [failure] whose Try again ([retry]) reopens New Bot with the same answers. The same answers + * replay the frozen attempt, request and Idempotency-Key alike, even after the Mac's catalog + * moved on, so a retry never makes two Bots. One Bot is made at a time. + * + * The ask, request and key alike, is on disk in [store] before anything goes out (a Create the + * phone couldn't keep is never sent) and stays until the Bot is made, the person lets it go, or + * access ends, so a process death mid-create comes back ([restore]) as not made. Its Try again + * sends the same request on the same key while the Mac still keeps that key (a day, less a + * margin: [aidenBotCreateReplayable]); after that, or when nothing could have reached the Mac, + * it reopens New Bot as it was, and only the person's own Create sends a new request. + * + * Each flow belongs to one pairing ([holdsPairing]): while another pairing is in use it starts + * nothing, restores nothing and retries nothing, so one Mac's answers never reach another's. + */ +class AidenBotCreateFlow( + private val waitForConnection: (suspend () -> Boolean)?, + private val create: suspend (AidenBotCreateRequest, UUID) -> AidenBotDetail, + /** The Mac's new Bot, kept for every screen. */ + private val cache: (AidenBotDetail) -> Unit, + private val store: AidenBotCreateStore? = null, + /** True while the pairing this flow was made for is still the one in use. */ + private val holdsPairing: () -> Boolean = { true }, + private val nowMs: () -> Long = System::currentTimeMillis, + private val attempts: AidenBotCreateAttempts = AidenBotCreateAttempts(nowMs), + private val holdMillis: Long = AIDEN_BOT_SAVE_HOLD_MILLIS, + private val limitMillis: Long = AIDEN_BOT_SAVE_LIMIT_MILLIS +) { + /** A Bot on its way to the Mac: Bots home shows its name and "Creating…". */ + data class Pending(val name: String) + + /** + * Why the last Create didn't make a Bot, and the answers Try again brings back. [replays]: + * an earlier launch's create that may have reached the Mac, which Try again sends again as + * it was. [request] and [sentAtMs] are set when the Mac may hold the Bot, so Bots home can + * let the failure go once the Mac's list shows it ([aidenBotCreateWasTaken]). + */ + data class Failure( + @StringRes val message: Int, + val draft: AidenBotCreateDraft, + val replays: Boolean = false, + val request: AidenBotCreateRequest? = null, + val sentAtMs: Long? = null + ) + + private val _pending = MutableStateFlow(null) + val pending: StateFlow = _pending.asStateFlow() + + private val _failure = MutableStateFlow(null) + val failure: StateFlow = _failure.asStateFlow() + + private val _created = MutableSharedFlow(extraBufferCapacity = 1) + /** Each Bot this flow made, as the Mac made it, to whoever is listening then. */ + val created: SharedFlow = _created.asSharedFlow() + + private var prefill: AidenBotCreateDraft? = null + private var restored = false + /** The write [start] queued for its attempt; [send] waits for it before anything goes out. */ + private var keeping: Pair>? = null + + /** True when [draft] would replay a try that may have reached the Mac. */ + fun replays(draft: AidenBotCreateDraft): Boolean = attempts.replays(draft) + + /** + * Shows [draft]'s Bot as on its way and returns the attempt for [send], or null when another + * Create is still on its way or no request can be built. [catalog] is the form's; it only + * shapes a new request, never a replayed one. The ask's write is queued here, so it is kept + * even if [send] never runs; [send] waits for it. + */ + fun start(draft: AidenBotCreateDraft, catalog: AidenBotCapabilityCatalog?): AidenBotCreateAttempt? { + if (_pending.value != null || !holdsPairing()) return null + return go(attempts.begin(draft, catalog) ?: return null) + } + + private fun go(attempt: AidenBotCreateAttempt): AidenBotCreateAttempt { + keeping = store?.let { attempt to it.save(attempt.kept()) } + prefill = null + _failure.value = null + _pending.value = Pending(attempt.request.name) + return attempt + } + + private fun AidenBotCreateAttempt.kept() = AidenBotPendingCreate(draft, request, key, createdAtMs) + + /** + * True once [attempt]'s request and key are on disk, so a process death after the Mac took + * it still brings back the same identity. Waits for the write [start] queued (or queues one). + */ + private suspend fun isKept(attempt: AidenBotCreateAttempt): Boolean { + val store = store ?: return true + val queued = keeping?.takeIf { it.first === attempt }?.second ?: store.save(attempt.kept()) + return queued.await() + } + + /** + * Makes [attempt]'s Bot once its ask is on disk; nothing goes out before. Run it where it + * outlives the form; it settles [pending] either way. + */ + suspend fun send(attempt: AidenBotCreateAttempt) { + try { + if (!isKept(attempt)) { + // Nothing went out: a Create this phone couldn't keep can't be recovered after a + // process death. Try again reopens New Bot with the answers. + val earlier = attempts.mayHaveArrived(attempt) + _failure.value = Failure( + R.string.bot_create_not_kept, + attempt.draft, + request = attempt.request.takeIf { earlier }, + sentAtMs = attempt.createdAtMs.takeIf { earlier } + ) + return + } + val made = aidenBotHeldRequest(waitForConnection, holdMillis, limitMillis) { create(attempt.request, attempt.key) } + attempts.succeeded(attempt) + store?.remove() + cache(made) + _pending.value = null + _created.tryEmit(made) + } catch (error: CancellationException) { + // Cut off (a process going away, another pairing): the ask stays kept as it was. + throw error + } catch (error: Exception) { + _pending.value = null + val message = if (aidenBotChatActivityMacUnreachable(error)) R.string.bot_editor_create_unreachable else R.string.bot_editor_create_failed + if (aidenBotLostAccess(error)) { + // This phone can't make Bots on this Mac any more: nothing of the ask is kept. + attempts.forget() + store?.remove() + _failure.value = Failure(R.string.bot_editor_create_failed, attempt.draft) + return + } + attempts.failed(attempt, error) + val mayHaveArrived = attempts.mayHaveArrived(attempt) + // Refused outright: a retry is a new request. The answers stay kept. + if (!mayHaveArrived && error !is AidenBotChatActivityUnreachable) { + store?.save(AidenBotPendingCreate(attempt.draft, createdAtMs = nowMs())) + } + _failure.value = Failure( + message, + attempt.draft, + request = attempt.request.takeIf { mayHaveArrived }, + sentAtMs = attempt.createdAtMs.takeIf { mayHaveArrived } + ) + } finally { + // A pairing change cancels it: nothing is on its way any more. + _pending.value = null + } + } + + /** + * Brings back the New Bot an earlier launch left on its way: Bots home shows it as not made, + * with Try again. It looks once, and never over a create or failure of this launch. A read + * that lands after another pairing came into use shows nothing; this pairing's next visit + * reads it again. + */ + suspend fun restore() { + val store = store ?: return + if (restored || !isFree() || !holdsPairing()) return + restored = true + val saved = try { + store.load() + } catch (error: CancellationException) { + restored = false + throw error + } catch (_: Exception) { + null + } + if (!holdsPairing()) { + restored = false + return + } + saved ?: return + if (!isFree()) return + val request = saved.request + val key = saved.key + // The Mac keeps a key for a day; past that the same key could make a second Bot. + val replays = request != null && key != null && aidenBotCreateReplayable(saved.createdAtMs, nowMs()) + if (replays) attempts.adopt(AidenBotCreateAttempt(saved.draft, request!!, key!!, saved.createdAtMs)) + _failure.value = Failure( + // It may have reached the Mac, which no longer keeps its key: say so before another Create. + if (request != null && !replays) R.string.bot_create_may_exist else R.string.bot_create_interrupted, + saved.draft, + replays = replays, + request = request, + sentAtMs = saved.createdAtMs.takeIf { request != null } + ) + } + + private fun isFree(): Boolean = _pending.value == null && _failure.value == null && !attempts.holdsUncertain + + /** + * Try again. An earlier launch's create that may have reached the Mac goes out again as it + * was, while the Mac still keeps its key: the attempt to [send] comes back. Otherwise + * (including a prompt left open past that) New Bot opens with the answers ([reopening]) and + * this returns null; only the person's own Create there sends a new request. + */ + fun retry(): AidenBotCreateAttempt? { + val failed = _failure.value ?: return null + // Another pairing is in use: nothing of this one goes out, or opens, there. + if (!holdsPairing() || _pending.value != null) return null + if (failed.replays) attempts.replay(failed.draft)?.let { return go(it) } + prefill = failed.draft + _failure.value = null + return null + } + + /** OK: the failure is gone and its answers no longer wait. In memory its key stays for the same answers. */ + fun dismissFailure() { + prefill = null + _failure.value = null + if (_pending.value == null) store?.remove() + } + + /** The Mac's list shows the Bot: the failure goes quietly, and nothing is left to replay. */ + fun settleTaken() { + prefill = null + _failure.value = null + attempts.forget() + if (_pending.value == null) store?.remove() + } + + /** What New Bot opens with after Try again; it stays until Create or the form is closed. */ + fun reopening(): AidenBotCreateDraft? = prefill + + /** New Bot, reopened by Try again, closed without Create: that Bot is let go. */ + fun closeReopened() { + if (prefill == null) return + prefill = null + if (_pending.value == null) store?.remove() + } +} + +/** + * True when the Mac's list already shows the Bot [failure] may have made: the same name and + * subtitle, made since the attempt went out (allowing for [skewMs] between the two clocks). + */ +fun aidenBotCreateWasTaken( + failure: AidenBotCreateFlow.Failure, + bots: List, + skewMs: Long = 2 * 60_000L +): Boolean { + val request = failure.request ?: return false + val since = failure.sentAtMs ?: return false + return bots.any { it.name == request.name && it.purpose == request.purpose && it.createdAt.toEpochMilli() >= since - skewMs } +} + +/** + * The Bot create flow for the pairing in use: its Bots are made on, and kept for, that pairing + * only. Its ask waits beside the pairing's chat outboxes, so an unpair purges it with them. + */ +fun AidenRemoteCoordinator.botCreateFlow(): AidenBotCreateFlow = pairingMemo("bot-create-flow") { + val host = botSaveHost() + val pairing = activePairing + val store = pairing?.let { + AidenBotCreateStore( + load = { botSessionStore.pendingCreate(pairing) }, + save = { botSessionStore.savePendingCreate(pairing, it) }, + remove = { botSessionStore.removePendingCreate(pairing) } + ) + } + AidenBotCreateFlow( + waitForConnection = host.waitForConnection, + create = { request, key -> host.connected().createBot(request, key) }, + cache = host.cache, + store = store, + // With no pairing, the flow stands only while none is paired (its creates can't reach a Mac). + holdsPairing = { activePairing == pairing } + ) } /** @@ -133,7 +463,7 @@ fun AidenBotEditorScreen( onBotSaved: (String) -> Unit ) { if (botId == null) { - AidenBotCreateScreen(coordinator, onNavigateBack, onBotSaved) + AidenBotCreateScreen(coordinator, onNavigateBack) } else { AidenBotInstructionsEditorScreen(botId, coordinator, onNavigateBack, onBotSaved) } @@ -189,18 +519,26 @@ private fun AidenBotEditorScaffold( @Composable private fun AidenBotCreateScreen( coordinator: AidenRemoteCoordinator, - onNavigateBack: () -> Unit, - onBotSaved: (String) -> Unit + onNavigateBack: () -> Unit ) { val palette = AidenTheme.palette - val resources = LocalResources.current - val scope = rememberCoroutineScope() val client by coordinator.client.collectAsStateWithLifecycle() - var draft by remember { mutableStateOf(AidenBotCreateDraft()) } - var isSaving by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } + // Create closes New Bot at once and Bots home shows the Bot on its way (R15). The flow and + // the save host outlive the form, so a retry keeps its answers and its idempotency key. + val flow = remember(client) { coordinator.botCreateFlow() } + val saveHost = remember(client) { coordinator.botSaveHost() } + var draft by remember { mutableStateOf(flow.reopening() ?: AidenBotCreateDraft()) } + // Closed without Create after Try again: that Bot is let go. + val close = { + flow.closeReopened() + onNavigateBack() + } + BackHandler(onBack = close) + val pending by flow.pending.collectAsStateWithLifecycle() // The Mac's models; if they don't load the field is absent and Create sends no access. + // Until the Mac answers (or while it connects) the field is a placeholder, never a spinner. var catalog by remember { mutableStateOf(null) } + var catalogSettled by remember { mutableStateOf(false) } LaunchedEffect(client) { val cl = client ?: return@LaunchedEffect // A failed refetch keeps the catalog already loaded. @@ -209,43 +547,21 @@ private fun AidenBotCreateScreen( } catch (e: CancellationException) { throw e } catch (_: Exception) { + } finally { + catalogSettled = true } } - // A retried Create never makes two Bots, and a changed one never reuses a refused key. - val attempts = remember { AidenBotCreateAttempts() } AidenBotEditorScaffold( title = stringResource(R.string.bot_editor_new_title), - actionLabel = if (isSaving) stringResource(R.string.bot_editor_creating) else stringResource(R.string.action_create), - actionEnabled = !isSaving && client != null && (attempts.replays(draft) || draft.request(catalog) != null), - onNavigateBack = onNavigateBack, + // Another Bot still on its way holds this one until it lands. + actionLabel = if (pending != null) stringResource(R.string.bot_editor_creating) else stringResource(R.string.action_create), + actionEnabled = pending == null && (flow.replays(draft) || draft.request(catalog) != null), + onNavigateBack = close, onAction = { - val cl = client ?: return@AidenBotEditorScaffold - val attempt = attempts.begin(draft, catalog) ?: return@AidenBotEditorScaffold - scope.launch { - isSaving = true - error = null - try { - val created = cl.createBot(attempt.request, attempt.key) - coordinator.botCache.putBotDetail(created) - onBotSaved(created.id) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - attempts.failed(attempt, e) - // The Mac may have refused the model (its service was signed out); a fresh - // catalog lets the field say so instead of only the generic message. - try { - catalog = cl.botCapabilityCatalog() - } catch (c: CancellationException) { - throw c - } catch (_: Exception) { - } - error = if (draft.modelIsGone(catalog)) null else resources.getString(R.string.bot_editor_create_failed) - } finally { - isSaving = false - } - } + val attempt = flow.start(draft, catalog) ?: return@AidenBotEditorScaffold + saveHost.launch { flow.send(attempt) } + onNavigateBack() } ) { AidenBotSemanticAvatarView( @@ -268,6 +584,17 @@ private fun AidenBotCreateScreen( placeholder = stringResource(R.string.bot_editor_help_placeholder), singleLine = false ) + if (catalog == null && !catalogSettled) { + // The model field's place while the Mac's models arrive. + val loading = stringResource(R.string.bot_advanced_loading) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.clearAndSetSemantics { contentDescription = loading } + ) { + AidenSkeletonBlock(width = 96.dp, height = 12.dp) + AidenSkeletonBlock(height = 56.dp, shape = MaterialTheme.shapes.large) + } + } catalog?.let { cat -> val providers = remember(cat) { aidenBotModelPickerProviders(cat) } val modelGone = draft.modelIsGone(cat) @@ -289,7 +616,7 @@ private fun AidenBotCreateScreen( Text( text = stringResource(R.string.bot_editor_model_gone), style = MaterialTheme.typography.bodySmall, - color = palette.danger, + color = aidenBotErrorTextColor(), modifier = Modifier.padding(horizontal = 4.dp) ) } @@ -301,7 +628,6 @@ private fun AidenBotCreateScreen( style = MaterialTheme.typography.bodySmall, color = palette.secondary ) - error?.let { Text(it, color = palette.danger, style = MaterialTheme.typography.bodySmall) } } } @@ -314,12 +640,10 @@ private fun AidenBotInstructionsEditorScreen( ) { val palette = AidenTheme.palette val resources = LocalResources.current - val scope = rememberCoroutineScope() val client by coordinator.client.collectAsStateWithLifecycle() var bot by remember { mutableStateOf(coordinator.botCache.getBotDetail(botId)) } var text by remember { mutableStateOf(bot?.instructions.orEmpty()) } var loaded by remember { mutableStateOf(bot != null) } - var isSaving by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } LaunchedEffect(botId, client) { @@ -344,33 +668,35 @@ private fun AidenBotInstructionsEditorScreen( val trimmed = text.trim() AidenBotEditorScaffold( title = stringResource(R.string.bot_profile_instructions), - actionLabel = if (isSaving) stringResource(R.string.action_saving) else stringResource(R.string.action_save), - actionEnabled = !isSaving && current != null && trimmed.isNotEmpty(), + actionLabel = stringResource(R.string.action_save), + actionEnabled = current != null && trimmed.isNotEmpty(), onNavigateBack = onNavigateBack, onAction = { - val cl = client ?: return@AidenBotEditorScaffold val b = current ?: return@AidenBotEditorScaffold if (trimmed == b.instructions) { onBotSaved(b.id) return@AidenBotEditorScaffold } - scope.launch { - isSaving = true - error = null - try { - val saved = cl.updateBotIdentity(b.id, b.revision, AidenBotIdentityPatch(instructions = trimmed)) - coordinator.botCache.putBotDetail(saved) - onBotSaved(saved.id) - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - error = resources.getString(R.string.bot_save_failed) - } finally { - isSaving = false - } + // Saved at once (R15): Profile shows the new instructions now, the save runs on the + // pairing's scope, and a failure puts them back and says so wherever the person is. + val editor = coordinator.botIdentityEditor(b) + coordinator.botSaveHost().launch { + editor.edit(AidenBotIdentityPatch(instructions = trimmed)) + editor.state.value.failure?.let { coordinator.presentError(resources.getString(aidenBotSaveFailureText(it))) } } + onBotSaved(b.id) } ) { + if (!loaded && error == null) { + // The instructions' place while they arrive, never a spinner. + val loading = stringResource(R.string.bot_advanced_loading) + AidenSkeletonBlock( + height = 200.dp, + shape = MaterialTheme.shapes.large, + modifier = Modifier.clearAndSetSemantics { contentDescription = loading } + ) + return@AidenBotEditorScaffold + } TextField( value = text, onValueChange = { text = it.take(AidenBotWire.MAX_INSTRUCTIONS_LENGTH) }, @@ -382,7 +708,7 @@ private fun AidenBotInstructionsEditorScreen( minLines = 8, modifier = Modifier.fillMaxWidth() ) - error?.let { Text(it, color = palette.danger, style = MaterialTheme.typography.bodySmall) } + error?.let { Text(it, color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall) } } } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotGeneratedAvatarLifecycle.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotGeneratedAvatarLifecycle.kt index 5b56d7bad..83592be4a 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotGeneratedAvatarLifecycle.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotGeneratedAvatarLifecycle.kt @@ -266,7 +266,7 @@ fun AidenBotGeneratedAvatarLifecycleView( if (error != null) { Text( text = error ?: "", - color = MaterialTheme.colorScheme.error, + color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall ) } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryController.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryController.kt index b6ac68345..1905302ae 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryController.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryController.kt @@ -24,9 +24,11 @@ interface AidenBotMemoryTransport { suspend fun edit(botId: String, edit: AidenBotMemoryEdit, key: UUID): AidenBotMemory } -class AidenRemoteBotMemoryTransport(private val client: AidenRemoteClient) : AidenBotMemoryTransport { - override suspend fun memory(botId: String) = client.botMemory(botId) - override suspend fun edit(botId: String, edit: AidenBotMemoryEdit, key: UUID) = client.editBotMemory(botId, edit, key) +/** The memory routes on [client]'s Mac, read at each request so the page needn't wait for the connection to open. */ +class AidenRemoteBotMemoryTransport(private val client: () -> AidenRemoteClient?) : AidenBotMemoryTransport { + private fun connected() = client() ?: throw AidenRemoteClientException.Disconnected() + override suspend fun memory(botId: String) = connected().botMemory(botId) + override suspend fun edit(botId: String, edit: AidenBotMemoryEdit, key: UUID) = connected().editBotMemory(botId, edit, key) } /** Why a memory write didn't land, in the terms the person sees. */ @@ -103,24 +105,44 @@ data class AidenBotMemoryUiState( ) /** - * Drives Profile → Memory: loads `GET /bots/{id}/memory`, saves one edit at a time through - * `POST /bots/{id}/memory/edits`, and refetches after every failed write because an error - * answer carries no view. + * Drives Profile → Memory: loads `GET /bots/{id}/memory` and saves edits through + * `POST /bots/{id}/memory/edits`. + * + * Every edit applies at once (R15): Save closes the dialog with the new words in place, Delete + * takes the row away, Erase empties both groups. The Mac's answer replaces that; a failure puts + * back what was there (unless the Mac's own view has replaced it since), says why, reopens an + * edit's dialog on the person's words, and reloads, because an error answer carries no view. + * Nothing waits for the connection to show a change: a request waits for it, up to + * [holdMillis], and is bounded by [limitMillis]. */ class AidenBotMemoryController( val botId: String, private val transport: AidenBotMemoryTransport, - private val keys: AidenBotActionKeys = AidenBotActionKeys() + private val keys: AidenBotActionKeys = AidenBotActionKeys(), + private val waitForConnection: (suspend () -> Boolean)? = null, + private val holdMillis: Long = AIDEN_BOT_SAVE_HOLD_MILLIS, + private val limitMillis: Long = AIDEN_BOT_SAVE_LIMIT_MILLIS ) { private val _state = MutableStateFlow(AidenBotMemoryUiState()) val state: StateFlow = _state.asStateFlow() - /** Reads the memory; returns false when it could not. */ + /** How many views the Mac has answered with, so a failure rolls back only what nothing newer replaced. */ + private var macViews = 0 + + private suspend fun request(block: suspend () -> T): T = aidenBotHeldRequest(waitForConnection, holdMillis, limitMillis, block) + + private fun macView(view: AidenBotMemory) { + macViews += 1 + _state.update { it.copy(view = view) } + } + + /** Reads the memory; returns false when it could not. Skeletons show until it does. */ suspend fun load(): Boolean { _state.update { it.copy(isLoading = true) } return try { - val view = transport.memory(botId) - _state.update { it.copy(view = view, isLoading = false, loadFailed = false) } + val view = request { transport.memory(botId) } + macView(view) + _state.update { it.copy(isLoading = false, loadFailed = false) } true } catch (error: CancellationException) { throw error @@ -147,47 +169,59 @@ class AidenBotMemoryController( } /** - * Saves the open edit. Success closes the dialog with the Mac's fresh view; a failure keeps - * it open with the person-facing reason and reloads the page underneath. + * Saves the open edit: the dialog closes with the new words in place at once. The Mac's view + * replaces them; a failure puts the entry back and reopens the dialog on the person's words + * with the reason, and reloads the page underneath. */ suspend fun saveEdit(): Boolean { val editing = _state.value.editing ?: return false if (!editing.canSave) return false + val before = _state.value.view ?: return false val edit = AidenBotMemoryEdit.Replace(editing.target, editing.entryId, editing.trimmed) - _state.update { it.copy(editing = editing.copy(isSaving = true, error = null)) } + val writes = macViews + _state.update { it.copy(view = before.replacing(editing.target, editing.entryId, editing.trimmed), editing = null, actionError = null) } val action = "replace:${editing.entryId}:${editing.trimmed}" return try { - val view = transport.edit(botId, edit, keys.key(action)) + val view = request { transport.edit(botId, edit, keys.key(action)) } keys.complete(action) - _state.update { it.copy(view = view, editing = null, actionError = null) } + macView(view) true } catch (error: CancellationException) { throw error } catch (error: Exception) { keys.failed(action, error) _state.update { current -> - current.copy(editing = current.editing?.copy(isSaving = false, error = aidenBotMemoryError(error))) + current.copy( + view = if (macViews == writes) before else current.view, + // Another edit opened since keeps its dialog; the reason shows under the page then. + editing = current.editing ?: editing.copy(isSaving = false, error = aidenBotMemoryError(error)), + actionError = if (current.editing != null) aidenBotMemoryError(error) else current.actionError + ) } load() false } } - /** Deletes one entry right away (phones have no Undo). A failure reloads the page. */ + /** Deletes one entry: the row goes at once (phones have no Undo). A failure brings it back and reloads. */ suspend fun delete(target: AidenBotMemoryTarget, entryId: String): Boolean { - if (_state.value.view?.readable != true || entryId in _state.value.deleting) return false + val before = _state.value.view ?: return false + if (!before.readable || entryId in _state.value.deleting) return false val action = "remove:$entryId" - _state.update { it.copy(deleting = it.deleting + entryId, actionError = null, eraseFailed = false) } + val writes = macViews + _state.update { + it.copy(view = before.without(target, entryId), deleting = it.deleting + entryId, actionError = null, eraseFailed = false) + } return try { - val view = transport.edit(botId, AidenBotMemoryEdit.Remove(target, entryId), keys.key(action)) + val view = request { transport.edit(botId, AidenBotMemoryEdit.Remove(target, entryId), keys.key(action)) } keys.complete(action) - _state.update { it.copy(view = view) } + macView(view) true } catch (error: CancellationException) { throw error } catch (error: Exception) { keys.failed(action, error) - _state.update { it.copy(actionError = aidenBotMemoryError(error)) } + _state.update { it.copy(view = if (macViews == writes) before else it.view, actionError = aidenBotMemoryError(error)) } load() false } finally { @@ -195,20 +229,22 @@ class AidenBotMemoryController( } } - /** Erases both groups. It is also the only way out of an unreadable memory. */ + /** Erases both groups at once. It is also the only way out of an unreadable memory. A failure brings everything back. */ suspend fun erase(): Boolean { - if (_state.value.isErasing || _state.value.view == null) return false - _state.update { it.copy(isErasing = true, actionError = null, eraseFailed = false) } + val before = _state.value.view ?: return false + if (_state.value.isErasing) return false + val writes = macViews + _state.update { it.copy(view = before.erased(), editing = null, isErasing = true, actionError = null, eraseFailed = false) } return try { - val view = transport.edit(botId, AidenBotMemoryEdit.Clear, keys.key(ERASE)) + val view = request { transport.edit(botId, AidenBotMemoryEdit.Clear, keys.key(ERASE)) } keys.complete(ERASE) - _state.update { it.copy(view = view, editing = null) } + macView(view) true } catch (error: CancellationException) { throw error } catch (error: Exception) { keys.failed(ERASE, error) - _state.update { it.copy(eraseFailed = true) } + _state.update { it.copy(view = if (macViews == writes) before else it.view, eraseFailed = true) } load() false } finally { @@ -221,6 +257,31 @@ class AidenBotMemoryController( } } +private fun AidenBotMemory.updating(target: AidenBotMemoryTarget, change: (AidenBotMemoryStore) -> AidenBotMemoryStore) = when (target) { + AidenBotMemoryTarget.USER -> copy(user = change(user)) + AidenBotMemoryTarget.MEMORY -> copy(memory = change(memory)) +} + +private fun AidenBotMemoryStore.withUsed(used: Int) = copy(usedChars = used.coerceIn(0, AidenBotMemoryWire.MAX_CHARS)) + +/** [entryId] with [text] in place, as the page shows it until the Mac answers (the Mac gives it a new id). */ +internal fun AidenBotMemory.replacing(target: AidenBotMemoryTarget, entryId: String, text: String) = updating(target) { store -> + val old = store.entries.firstOrNull { it.id == entryId } ?: return@updating store + store.copy(entries = store.entries.map { if (it.id == entryId) AidenBotMemoryEntry(entryId, text) else it }) + .withUsed(store.usedChars - old.text.length + text.length) +} + +internal fun AidenBotMemory.without(target: AidenBotMemoryTarget, entryId: String) = updating(target) { store -> + val old = store.entries.firstOrNull { it.id == entryId } ?: return@updating store + store.copy(entries = store.entries - old).withUsed(store.usedChars - old.text.length) +} + +internal fun AidenBotMemory.erased() = copy( + readable = true, + user = user.copy(entries = emptyList(), usedChars = 0, overBudget = false), + memory = memory.copy(entries = emptyList(), usedChars = 0, overBudget = false) +) + /** Keeps a draft within the 500-character entry limit, counting code points. */ fun aidenBotMemoryClamp(text: String): String { val limit = AidenBotMemoryWire.MAX_ENTRY_LENGTH diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryScreen.kt index 0c6b79e92..b5c6691ae 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryScreen.kt @@ -59,9 +59,14 @@ fun AidenBotMemoryScreen( val client by coordinator.client.collectAsStateWithLifecycle() val name = rememberAidenBotChatIdentity(coordinator, botId, "").name val cl = client - val controller = remember(botId, cl) { cl?.let { AidenBotMemoryController(botId, AidenRemoteBotMemoryTransport(it)) } } - LaunchedEffect(controller) { controller?.load() } - val ui = controller?.state?.collectAsStateWithLifecycle()?.value + // One page per pairing; it reads the client at each request and waits for the Mac, so it + // opens on skeletons, never a "connect" screen, and its edits apply at once (R15). + val saveHost = remember(cl) { coordinator.botSaveHost() } + val controller = remember(botId, saveHost) { + AidenBotMemoryController(botId, AidenRemoteBotMemoryTransport(saveHost.client), waitForConnection = saveHost.waitForConnection) + } + LaunchedEffect(controller, cl) { controller.load() } + val ui = controller.state.collectAsStateWithLifecycle().value var confirmingErase by remember { mutableStateOf(false) } Scaffold( @@ -78,14 +83,8 @@ fun AidenBotMemoryScreen( ) } ) { padding -> - val view = ui?.view + val view = ui.view when { - controller == null || ui == null -> AidenEmptyState( - icon = Icons.Default.CloudOff, - title = stringResource(R.string.bot_memory_title), - body = stringResource(R.string.bot_memory_connect), - modifier = Modifier.fillMaxSize().padding(padding) - ) view == null && ui.isLoading -> AidenBotMemorySkeleton(Modifier.padding(padding)) view == null -> AidenEmptyState( icon = Icons.Default.CloudOff, @@ -131,14 +130,14 @@ fun AidenBotMemoryScreen( ui.actionError?.let { error -> Text( aidenBotMemoryActionErrorText(error), - color = palette.danger, + color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall ) } if (ui.eraseFailed) { Text( stringResource(R.string.bot_memory_erase_failed), - color = palette.danger, + color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall ) } @@ -152,8 +151,8 @@ fun AidenBotMemoryScreen( } } - val editing = ui?.editing - if (controller != null && editing != null) { + val editing = ui.editing + if (editing != null) { val groupName = when (editing.target) { AidenBotMemoryTarget.USER -> stringResource(R.string.bot_memory_about_you) AidenBotMemoryTarget.MEMORY -> stringResource(R.string.bot_memory_notes, name) @@ -183,7 +182,7 @@ fun AidenBotMemoryScreen( modifier = Modifier.fillMaxWidth() ) editing.error?.let { error -> - Surface(color = palette.danger.copy(alpha = 0.12f), shape = MaterialTheme.shapes.medium) { + Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) { Text( when (error) { AidenBotMemoryError.BLOCKED -> stringResource(R.string.bot_memory_error_blocked) @@ -191,7 +190,7 @@ fun AidenBotMemoryScreen( AidenBotMemoryError.NOT_FOUND -> stringResource(R.string.bot_memory_error_not_found) AidenBotMemoryError.FAILED -> stringResource(R.string.bot_memory_error_failed) }, - color = palette.danger, + color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(12.dp) ) @@ -212,7 +211,7 @@ fun AidenBotMemoryScreen( ) } - if (controller != null && confirmingErase) { + if (confirmingErase) { AlertDialog( onDismissRequest = { confirmingErase = false }, title = { Text(stringResource(R.string.bot_memory_erase_title, name)) }, @@ -221,7 +220,7 @@ fun AidenBotMemoryScreen( AidenDialogConfirmButton( text = stringResource(R.string.bot_memory_erase), destructive = true, - enabled = ui?.isErasing != true, + enabled = !ui.isErasing, onClick = { confirmingErase = false scope.launch { controller.erase() } @@ -331,7 +330,7 @@ private fun AidenBotMemoryRow( } ) DropdownMenuItem( - text = { Text(stringResource(R.string.action_delete), color = palette.danger) }, + text = { Text(stringResource(R.string.action_delete), color = aidenBotErrorTextColor()) }, leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null, tint = palette.danger) }, onClick = { showMenu = false diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSaves.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSaves.kt new file mode 100644 index 000000000..cc62961f4 --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSaves.kt @@ -0,0 +1,266 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.flow.map +import sbtbiswas.AidenOnTheGo.features.remote.AidenRemoteCoordinator +import sbtbiswas.AidenOnTheGo.models.AidenBotDetail +import sbtbiswas.AidenOnTheGo.models.AidenBotIdentityPatch +import sbtbiswas.AidenOnTheGo.networking.AidenRemoteClient +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteClientException + +// R15: the slice-A Bot surfaces (create, model pick, Advanced Save, profile and memory edits) +// apply at once, never wait for the connection to show a change, and roll back on failure. + +/** + * What a Bot edit needs from its surroundings, bound to one pairing: the client at request + * time, a wait for the connection, a scope that outlives the screen (so leaving mid-save + * doesn't cancel the request), and the pinned cache: [show] for what the Mac hasn't confirmed + * (memory only), [cache] for what it has (memory and disk). + */ +class AidenBotSaveHost( + /** The pairing's client now, or null once that pairing is gone or the phone is offline. */ + val client: () -> AidenRemoteClient?, + /** Suspends until the Mac is connected; false when it can't be (unpaired, another pairing). */ + val waitForConnection: suspend () -> Boolean, + val launch: (suspend () -> Unit) -> Unit, + /** Every screen shows this Bot now; a relaunch doesn't (an edit on its way, a rollback). */ + val show: (AidenBotDetail) -> Unit, + /** The Mac sent or confirmed this Bot: kept, on disk too. */ + val cache: (AidenBotDetail) -> Unit, + /** The Bot the cache holds now, to roll back only what nothing newer replaced. */ + val cached: (String) -> AidenBotDetail? = { null } +) { + /** The client, or [AidenRemoteClientException.Disconnected] when there is none. */ + fun connected(): AidenRemoteClient = client() ?: throw AidenRemoteClientException.Disconnected() +} + +/** The save host for the pairing in use now; nothing it saves reaches another pairing. */ +fun AidenRemoteCoordinator.botSaveHost(): AidenBotSaveHost = pairingMemo("bot-save-host") { + val pairing = activePairing + fun samePairing() = holdsPairing(pairing) + AidenBotSaveHost( + client = { pairedClient(pairing) }, + waitForConnection = { samePairing() && aidenBotAwaitConnected(connectionState, client.map { pairedClient(pairing) }) && samePairing() }, + launch = { block -> launchDetached(block) }, + show = pinnedBotShow(), + cache = pinnedBotCache(), + cached = { id -> if (samePairing()) botCache.getBotDetail(id) else null } + ) +} + +/** How long a save waits for the Mac to connect before it gives up (R15), as Chat activity does. */ +const val AIDEN_BOT_SAVE_HOLD_MILLIS = AidenBotChatActivityController.CONNECTION_HOLD_MILLIS + +/** The bound on each save request, as on iOS. */ +const val AIDEN_BOT_SAVE_LIMIT_MILLIS = AidenBotChatActivityController.REQUEST_LIMIT_MILLIS + +/** + * Runs [request] once the Mac is connected (waiting up to [holdMillis]) and within + * [limitMillis]. Throws [AidenBotChatActivityUnreachable] when the Mac never connected and + * [AidenBotChatActivityTimeout] when the request outran its limit. + */ +suspend fun aidenBotHeldRequest( + waitForConnection: (suspend () -> Boolean)?, + holdMillis: Long = AIDEN_BOT_SAVE_HOLD_MILLIS, + limitMillis: Long = AIDEN_BOT_SAVE_LIMIT_MILLIS, + request: suspend () -> T +): T { + if (waitForConnection != null && withTimeoutOrNull(holdMillis) { waitForConnection() } != true) { + throw AidenBotChatActivityUnreachable() + } + return withTimeoutOrNull(limitMillis) { request() } ?: throw AidenBotChatActivityTimeout() +} + +/** The words for a save that didn't stick. */ +@androidx.annotation.StringRes +fun aidenBotSaveFailureText(failure: AidenBotChatActivityFailure): Int = when (failure) { + AidenBotChatActivityFailure.GENERIC -> sbtbiswas.AidenOnTheGo.R.string.bot_save_failed + AidenBotChatActivityFailure.OFFLINE -> sbtbiswas.AidenOnTheGo.R.string.bot_save_unreachable + AidenBotChatActivityFailure.CONFLICT -> sbtbiswas.AidenOnTheGo.R.string.bot_chat_activity_save_conflict +} + +/** Why a save didn't stick, in the person's terms. */ +fun aidenBotSaveFailure(error: Throwable, macHoldsSomethingElse: Boolean = false): AidenBotChatActivityFailure = when { + aidenBotIsRevisionConflict(error) || macHoldsSomethingElse -> AidenBotChatActivityFailure.CONFLICT + aidenBotChatActivityMacUnreachable(error) -> AidenBotChatActivityFailure.OFFLINE + else -> AidenBotChatActivityFailure.GENERIC +} + +/** This Bot with [patch]'s identity fields in place; null leaves it as it is. */ +fun AidenBotDetail.applying(patch: AidenBotIdentityPatch?): AidenBotDetail { + patch ?: return this + return copy( + name = patch.name ?: name, + purpose = patch.purpose ?: purpose, + instructions = patch.instructions ?: instructions, + avatar = patch.avatar?.let { avatar.copy(semantic = it) } ?: avatar, + chatActivity = patch.chatActivity ?: chatActivity + ) +} + +/** One patch with [next]'s fields over this one's: the later edit of a field wins. */ +fun AidenBotIdentityPatch.then(next: AidenBotIdentityPatch) = AidenBotIdentityPatch( + name = next.name ?: name, + purpose = next.purpose ?: purpose, + instructions = next.instructions ?: instructions, + avatar = next.avatar ?: avatar, + chatActivity = next.chatActivity ?: chatActivity +) + +data class AidenBotIdentityEditState( + /** What the page shows: the Mac's Bot with this phone's edits on top, saved or not. */ + val bot: AidenBotDetail, + val isSaving: Boolean = false, + /** Set when the last edit didn't stick (its fields went back); cleared by the next edit. */ + val failure: AidenBotChatActivityFailure? = null +) + +/** + * A Bot's name, subtitle, character and instructions edits from this phone (R15). Each applies + * at once and saves in the background, in order, on the newest revision; edits made while one + * saves go out together next. A failed edit's fields go back to what the Mac holds (edits made + * since stay) and [AidenBotIdentityEditState.failure] says why. A stale revision or no answer + * refreshes the Bot first, so an edit that landed late shows as saved. + * + * Nothing waits for the connection to show a change: a save waits for it, up to + * [holdMillis], and each request is bounded by [limitMillis]. A request that has gone out + * finishes, and reports through [onSaved], even if the caller leaves. Confined to one thread + * (the UI's): the first caller of [edit] drives the saves; later callers return at once. + */ +class AidenBotIdentityEditor( + bot: AidenBotDetail, + private val save: suspend (saved: AidenBotDetail, patch: AidenBotIdentityPatch) -> AidenBotDetail, + private val refresh: (suspend () -> AidenBotDetail)? = null, + /** The Mac's Bot after a save or a refresh: tell the screens behind. */ + private val onSaved: (AidenBotDetail) -> Unit = {}, + /** What the page shows changed (an edit, a rollback, the Mac's Bot): every screen shows it at once, and only [onSaved]'s Bot is kept. */ + private val onShown: (AidenBotDetail) -> Unit = {}, + private val waitForConnection: (suspend () -> Boolean)? = null, + private val holdMillis: Long = AIDEN_BOT_SAVE_HOLD_MILLIS, + private val limitMillis: Long = AIDEN_BOT_SAVE_LIMIT_MILLIS +) { + /** The last Bot the Mac confirmed. */ + private var saved = bot + private var inFlight: AidenBotIdentityPatch? = null + private var pending: AidenBotIdentityPatch? = null + private var driving = false + private var failure: AidenBotChatActivityFailure? = null + + private val _state = MutableStateFlow(AidenBotIdentityEditState(bot)) + val state: StateFlow = _state.asStateFlow() + + /** True while an edit is saving or waiting to. */ + val isBusy: Boolean get() = driving || pending != null + + private fun publish() { + val shown = saved.applying(inFlight).applying(pending) + val changed = shown != _state.value.bot + _state.value = AidenBotIdentityEditState(shown, driving, failure) + if (changed) onShown(shown) + } + + /** A newer Bot from the Mac (a read, another screen's save). Taken only while nothing here is unsaved, and never an older one. */ + fun adopt(fresh: AidenBotDetail) { + if (isBusy || fresh.id != saved.id || fresh.updatedAt < saved.updatedAt) return + saved = fresh + publish() + } + + fun dismissError() { + failure = null + publish() + } + + /** Shows [patch] at once and saves it. Returns once this call's saves have settled (or at once if another call drives). */ + suspend fun edit(patch: AidenBotIdentityPatch) { + pending = pending?.then(patch) ?: patch + failure = null + publish() + if (driving) return + driving = true + publish() + try { + while (true) { + val next = pending ?: break + pending = null + inFlight = next + publish() + // The request, and the report that follows it, are not cancelled with the caller. + withContext(NonCancellable) { send(next) } + inFlight = null + publish() + currentCoroutineContext().ensureActive() + } + } finally { + driving = false + inFlight = null + publish() + } + } + + private suspend fun send(patch: AidenBotIdentityPatch) { + try { + val fresh = aidenBotHeldRequest(waitForConnection, holdMillis, limitMillis) { save(saved, patch) } + saved = fresh + onSaved(fresh) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + val refreshes = aidenBotIsRevisionConflict(error) || aidenBotChatActivityTransportFailure(error) + val fresh = if (refreshes) adoptTheMacs() else null + // No answer came, but the Mac holds the edit: it landed. + if (fresh != null && fresh.applying(patch) == fresh) return + failure = aidenBotSaveFailure(error) + } + } + + private suspend fun adoptTheMacs(): AidenBotDetail? { + val refresh = refresh ?: return null + val fresh = try { + withTimeoutOrNull(limitMillis) { refresh() } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + null + } ?: return null + saved = fresh + onSaved(fresh) + return fresh + } +} + +/** + * An editor that saves through the Mac in [host]'s pairing. Every screen shows what it shows + * at once ([AidenBotSaveHost.show]), and it goes back with a rollback; only the Mac's answer is + * kept ([AidenBotSaveHost.cache]), so a relaunch mid-save shows the Mac's Bot (as iOS does). + */ +fun aidenBotIdentityEditor( + bot: AidenBotDetail, + host: AidenBotSaveHost, + onSaved: (AidenBotDetail) -> Unit = {} +) = AidenBotIdentityEditor( + bot, + save = { saved, patch -> host.connected().updateBotIdentity(saved.id, saved.revision, patch) }, + refresh = { host.connected().bot(bot.id) }, + onSaved = { confirmed -> + host.cache(confirmed) + onSaved(confirmed) + }, + onShown = host.show, + waitForConnection = host.waitForConnection +) + +/** + * One editor per Bot for the pairing in use, shared by Profile and the Instructions editor so + * their edits save in order on one revision. A later visit brings it the newest Bot it has. + */ +fun AidenRemoteCoordinator.botIdentityEditor(bot: AidenBotDetail): AidenBotIdentityEditor = + pairingMemo("bot-identity-editor:${bot.id}") { aidenBotIdentityEditor(bot, botSaveHost()) }.also { it.adopt(bot) } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt new file mode 100644 index 000000000..70ef9a9d2 --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt @@ -0,0 +1,677 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import androidx.core.util.AtomicFile +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.encodeToJsonElement +import sbtbiswas.AidenOnTheGo.features.remote.AidenPairingIdentity +import sbtbiswas.AidenOnTheGo.models.AidenBotCreateRequest +import sbtbiswas.AidenOnTheGo.models.AidenBotSessionEntry +import sbtbiswas.AidenOnTheGo.models.AidenBotWireJson +import sbtbiswas.AidenOnTheGo.models.AidenBotSessionWire +import sbtbiswas.AidenOnTheGo.models.AidenStreamInputMode +import java.io.File +import java.security.MessageDigest +import java.util.UUID +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException + +/** + * Names one Bot chat across controllers and launches: its Mac, this pairing's device and the + * Bot. The pairing's `deviceId` is part of it, so a re-paired phone never sees the old + * pairing's messages. + */ +data class AidenBotSessionKey(val instanceId: String, val deviceId: String, val botId: String) + +/** Where a message this phone sent stands (R14). */ +enum class AidenBotOutgoingStatus { + /** Waiting for a connection, or on its way to the Mac. */ + SENDING, + /** The Mac took it ([AidenBotOutgoingMessage.submissionId]); its entry or Queued bubble is on the way. */ + SENT, + /** It didn't reach the Mac: "Couldn't send · Try again". */ + FAILED +} + +/** + * A message the person sent that the Mac's transcript doesn't show yet (R14, R17). It shows at + * once and gives way to the Mac's own entry or Queued bubble. + */ +data class AidenBotOutgoingMessage( + /** The bubble's identity; it never changes, even when Try again needs a new key. */ + val id: String, + /** The Idempotency-Key: every attempt uses it unless the Mac definitely refused it. */ + val key: UUID, + val text: String, + /** Steer or Queue, when it was sent while the Bot worked on a Mac that offers them. */ + val mode: AidenStreamInputMode?, + /** Sent while the Bot worked: it reads as Queued until the turn takes it in. */ + val enqueuedWhileRunning: Boolean, + /** The failed turn this message sends again (R18), or null. */ + val retryOf: String?, + val createdAtMs: Long, + val status: AidenBotOutgoingStatus = AidenBotOutgoingStatus.SENDING, + /** The Mac's id for it, once [status] is [AidenBotOutgoingStatus.SENT]. */ + val submissionId: String? = null, + /** The first attempt fixed the request body; later attempts under the same key resend it unchanged. */ + val frozen: Boolean = false, + /** The `whenBusy` the first attempt sent. */ + val sentMode: AidenStreamInputMode? = null, + /** Entry ids the transcript held at the first attempt: the Mac's echo is a newer entry. */ + val baseline: Set = emptySet(), + val connectionAttempts: Int = 0, + /** The last failure may have reached the Mac, so Try again keeps the key. */ + val keepsKey: Boolean = false, + // What this run of the controller has seen; never stored. + /** The Mac's Queued bubble for it; while that shows, this copy is hidden. */ + val queuedId: String? = null, + /** A transcript entry already stands for it while its request is unsettled: hidden until it settles. */ + val echoed: Boolean = false, + val echoEntryId: String? = null, + /** The submission that entry names, if any: only a receipt with that id settles it. */ + val echoSubmissionId: String? = null, + /** Waits for the next connection or backoff before trying again. */ + val deferred: Boolean = false, + /** Taken, but a snapshot found nothing on the Mac showing it: it goes on the next, or after a grace period. */ + val unheld: Boolean = false +) { + /** Whether the Mac may hold it, so its echo can stand in for it. */ + val mayHaveReachedMac: Boolean + get() = frozen && (status != AidenBotOutgoingStatus.FAILED || keepsKey) + + /** Too old to send: "Couldn't send · Try again", keeping the key if the Mac may hold it. */ + fun expired(): AidenBotOutgoingMessage = + copy(status = AidenBotOutgoingStatus.FAILED, keepsKey = frozen, deferred = false, echoed = false) + + /** As another visit or launch finds it: what that run saw is stale. */ + fun adopted(nowMs: Long): AidenBotOutgoingMessage { + val fresh = copy(deferred = false, echoed = false, echoEntryId = null, echoSubmissionId = null, queuedId = null, unheld = false) + return if (fresh.status == AidenBotOutgoingStatus.SENDING && fresh.isExpired(nowMs)) fresh.expired() else fresh + } + + fun isExpired(nowMs: Long): Boolean = nowMs - createdAtMs >= AidenBotSessionStore.LIFETIME_MS + + companion object { + fun create( + text: String, + mode: AidenStreamInputMode?, + enqueuedWhileRunning: Boolean, + retryOf: String?, + nowMs: Long, + key: UUID = UUID.randomUUID() + ) = AidenBotOutgoingMessage( + id = "outgoing:${UUID.randomUUID()}", + key = key, + text = text, + mode = mode, + enqueuedWhileRunning = enqueuedWhileRunning, + retryOf = retryOf, + createdAtMs = nowMs + ) + } +} + +/** A chat's unsent messages read back from disk, and whether some of them couldn't be (FR-I2). */ +data class AidenBotOutboxRestore(val messages: List, val unreadable: Boolean) + +/** + * A New Bot on its way for one pairing (R15), kept on disk so a process death mid-create + * brings it back: the person's [draft], the [request] exactly as it went out (null when nothing + * that could reach the Mac was sent) and its idempotency [key] (null too once the Mac no longer + * keeps it, [aidenBotCreateReplayable]), and when it began. + */ +data class AidenBotPendingCreate( + val draft: AidenBotCreateDraft, + val request: AidenBotCreateRequest? = null, + val key: UUID? = null, + val createdAtMs: Long +) { + init { + require(key == null || request != null) + } +} + +/** + * Whether a Create that began at [createdAtMs] may still go out again on its key. The Mac keeps + * a settled key for a day from when it settled, which is after the phone began it; the phone + * replays only for [AidenBotSessionStore.CREATE_REPLAY_MS] of its own clock, so a Mac clock + * that runs ahead or a clock change of up to [AidenBotSessionStore.CREATE_REPLAY_MARGIN_MS] + * either way never sends a key the Mac has let go (which would make a second Bot). + */ +fun aidenBotCreateReplayable(createdAtMs: Long, nowMs: Long): Boolean { + val age = nowMs - createdAtMs + return age > -AidenBotSessionStore.CREATE_REPLAY_MARGIN_MS && age < AidenBotSessionStore.CREATE_REPLAY_MS +} + +/** How the chat draws an outgoing message (R14). */ +sealed class AidenBotOutgoingLook { + /** Faded, "Sending…". */ + object Sending : AidenBotOutgoingLook() + /** The Mac took it; its own entry replaces it shortly. */ + object Sent : AidenBotOutgoingLook() + /** The Bot is working: Queued, as Steer or Queue. */ + data class Queued(val mode: AidenStreamInputMode) : AidenBotOutgoingLook() + /** "Couldn't send · Try again". */ + object Failed : AidenBotOutgoingLook() +} + +/** + * What each Bot chat last showed in this run of the app, and its unsent messages (R14, R17). + * Transcripts live in memory only (the last [CAPACITY] chats). Outboxes also live on disk under + * [root], which the app puts in `noBackupFilesDir`, so they survive leaving the chat, a Bot + * switch and process death but never reach a backup. Each chat's outbox has one writer at a + * time ([adoptOutbox]); an older controller left behind can't overwrite it, and nothing written + * after an unpair ([purgeInstance]) is kept. Disk writes run in order on [io], so a purge always + * lands after the writes before it. + */ +class AidenBotSessionStore( + private val root: File?, + private val io: Executor = defaultIo, + private val nowMs: () -> Long = System::currentTimeMillis, + private val report: (String) -> Unit = {}, + /** + * The most one chat's outbox may hold; a message past it is refused at enqueue. A later + * launch reads back up to twice this, so a restore that merged two full outboxes still reads. + */ + private val maxBytes: Int = MAX_BYTES +) { + private val transcripts = LinkedHashMap>(CAPACITY, 0.75f, true) + private val outboxes = HashMap>() + private val read = HashSet() + private val owners = HashMap() + /** Chats whose unreadable file couldn't be moved aside: it is never written over. */ + private val stuck = HashSet() + /** Pairings whose New Bot on its way this run of the app has touched. */ + private val createPairings = HashSet() + /** Pairings an unpair purged: no New Bot is kept for them again (pairing that Mac again is another pairing). */ + private val revokedCreates = HashSet() + + @Synchronized + fun transcript(key: AidenBotSessionKey): List? = transcripts[key] + + @Synchronized + fun storeTranscript(key: AidenBotSessionKey, entries: List) { + transcripts[key] = entries + while (transcripts.size > CAPACITY) transcripts.remove(transcripts.keys.first()) + } + + /** + * Makes [owner] the only writer of the chat's outbox. Returns its messages when this run of + * the app already read them (no disk access), or null when [restoreOutbox] must read the disk. + */ + @Synchronized + fun adoptOutbox(key: AidenBotSessionKey, owner: Any): List? { + owners[key] = owner + return if (key in read) outboxes[key].orEmpty() else null + } + + /** + * The chat's messages, oldest first, read from disk on [io] (never the caller's thread) the + * first time in this run of the app, and whether some couldn't be read back (FR-I2). Disk + * reads, writes and purges all run in order on [io], so a purge that came first leaves + * nothing to read. The read itself runs outside the store's lock: messages sent meanwhile + * wait in memory and follow the older disk copy. Empty once [owner] no longer writes it. + * The caller always resumes, with the store's answer or with the error that stopped it. + */ + suspend fun restoreOutbox(key: AidenBotSessionKey, owner: Any): AidenBotOutboxRestore = + suspendCancellableCoroutine { continuation -> + val task = Runnable { continuation.resumeWith(runCatching { restoreNow(key, owner) }) } + try { + io.execute(task) + } catch (error: RejectedExecutionException) { + continuation.resumeWith(Result.failure(error)) + } + } + + private fun restoreNow(key: AidenBotSessionKey, owner: Any): AidenBotOutboxRestore { + synchronized(this) { + if (owners[key] !== owner) return AidenBotOutboxRestore(emptyList(), unreadable = false) + if (key in read) return AidenBotOutboxRestore(outboxes[key].orEmpty(), unreadable = false) + } + val disk = load(key) + val merged: List + val rewrite: Boolean + synchronized(this) { + // Another controller took the chat, or a purge or unpair came while the disk was read. + if (owners[key] !== owner || !read.add(key)) { + return AidenBotOutboxRestore(if (owners[key] === owner) outboxes[key].orEmpty() else emptyList(), disk.unreadable) + } + val known = disk.messages.mapTo(HashSet()) { it.key } + val pending = outboxes[key].orEmpty().filterNot { it.key in known } + merged = disk.messages + pending + if (merged.isEmpty()) outboxes.remove(key) else outboxes[key] = merged + rewrite = pending.isNotEmpty() || disk.changed + } + // Still on [io]: a write or purge queued after this read lands after this write. + if (rewrite) write(key, merged.map(Record::of)) + return AidenBotOutboxRestore(merged, disk.unreadable) + } + + /** + * Keeps [messages] when [owner] writes this chat; false when another controller does, or + * after an unpair. Until the disk copy was read ([restoreOutbox]) they wait in memory, so they + * never overwrite what a previous launch left. + */ + @Synchronized + fun storeOutbox(key: AidenBotSessionKey, owner: Any, messages: List): Boolean { + if (owners[key] !== owner) return false + if (messages.isEmpty()) outboxes.remove(key) else outboxes[key] = messages + if (key in read) { + val records = messages.map(Record::of) + io.execute { write(key, records) } + } + return true + } + + /** Whether [messages] fit the file a later launch will read back; a message past that is refused, never stored. */ + fun fits(key: AidenBotSessionKey, messages: List): Boolean = + messages.size <= MAX_MESSAGES && encode(key, messages.map(Record::of)).size <= maxBytes + + /** This phone lost access to the chat (401/403, accessChanged): nothing of it is kept. */ + @Synchronized + fun purge(key: AidenBotSessionKey) { + transcripts.remove(key) + outboxes.remove(key) + read += key + stuck -= key + io.execute { fileFor(key)?.let { AtomicFile(it).delete() } } + } + + /** + * The Mac was unpaired: every chat of it goes, and no controller still open may write or + * restore again. Its chats stay marked read (and empty), and the files go on [io] after any + * write already queued. + */ + @Synchronized + fun purgeInstance(instanceId: String) { + transcripts.keys.removeAll { it.instanceId == instanceId } + outboxes.keys.removeAll { it.instanceId == instanceId } + owners.keys.removeAll { it.instanceId == instanceId } + stuck.removeAll { it.instanceId == instanceId } + revokedCreates += createPairings.filter { it.instanceId == instanceId } + val dir = root?.let { File(it, digest(instanceId)) } + io.execute { dir?.deleteRecursively() } + } + + // --- A New Bot on its way (R15) --- + + /** + * The New Bot an earlier launch left on its way for this [pairing], read on [io] (after any + * write or purge queued before it), or null. A file this build can't read is set aside as + * `.unreadable`, never deleted; one an earlier pairing of the same Mac and device left (its + * unpair's purge never landed) is that pairing's, and goes. Its age is the caller's to judge + * (see [AidenBotCreateFlow]). + */ + suspend fun pendingCreate(pairing: AidenPairingIdentity): AidenBotPendingCreate? = + suspendCancellableCoroutine { continuation -> + synchronized(this) { createPairings += pairing } + val task = Runnable { continuation.resumeWith(runCatching { loadCreate(pairing) }) } + try { + io.execute(task) + } catch (error: RejectedExecutionException) { + continuation.resumeWith(Result.failure(error)) + } + } + + /** + * Keeps the pairing's New Bot on its way; never for a pairing an unpair purged. The write is + * queued at once, in order with the store's other disk work, and the answer completes once it + * has finished on [io]: true when the record is synced and in place, so a process death from + * then on brings it back, false when it couldn't be kept. Nothing of the create may go out + * before that answer is true ([AidenBotCreateFlow.send]). + */ + fun savePendingCreate(pairing: AidenPairingIdentity, create: AidenBotPendingCreate): Deferred { + val kept = CompletableDeferred() + synchronized(this) { + createPairings += pairing + if (pairing in revokedCreates) return kept.apply { complete(false) } + } + val record = CreateRecord.of(pairing, create) + try { + io.execute { + var written = false + try { + written = writeCreate(pairing, record) + } finally { + kept.complete(written) + } + } + } catch (_: RejectedExecutionException) { + kept.complete(false) + } + return kept + } + + /** The New Bot was made, let go of, or this phone lost access: nothing of it is kept. */ + fun removePendingCreate(pairing: AidenPairingIdentity) { + io.execute { createFileFor(pairing)?.let { AtomicFile(it).delete() } } + } + + @Serializable + private data class CreateRecord( + val version: Int, + val instanceId: String, + val deviceId: String, + /** When the pairing it belongs to was made, in epoch milliseconds (as the pairing is stored). */ + val pairedAt: Long? = null, + val name: String, + val help: String, + val providerId: String? = null, + val modelId: String? = null, + /** The request body exactly as it went out, in the wire's own JSON. */ + val body: String? = null, + val key: String? = null, + val createdAt: Long + ) { + fun create(): AidenBotPendingCreate { + val route = if (providerId != null && modelId != null) { + sbtbiswas.AidenOnTheGo.features.shared.AidenModelRoute(providerId, modelId) + } else { + null + } + val request = body?.let { AidenBotWireJson.json.decodeFromString(AidenBotCreateRequest.serializer(), it) } + val idempotencyKey = key?.let(UUID::fromString) + return AidenBotPendingCreate(AidenBotCreateDraft(name, help, route), request, idempotencyKey, createdAt) + } + + companion object { + fun of(pairing: AidenPairingIdentity, create: AidenBotPendingCreate) = CreateRecord( + version = 1, + instanceId = pairing.instanceId, + deviceId = pairing.deviceId, + pairedAt = pairing.pairedAtMs, + name = create.draft.name, + help = create.draft.help, + providerId = create.draft.model?.providerId, + modelId = create.draft.model?.modelId, + body = create.request?.let { AidenBotWireJson.json.encodeToString(AidenBotCreateRequest.serializer(), it) }, + key = create.key?.toString(), + createdAt = create.createdAtMs + ) + } + } + + private fun loadCreate(pairing: AidenPairingIdentity): AidenBotPendingCreate? { + val file = createFileFor(pairing) ?: return null + if (!file.exists() && !File(file.path + ".bak").exists()) return null + val record = try { + val bytes = AtomicFile(file).readFully() + if (bytes.size > MAX_CREATE_BYTES) throw IllegalStateException("too large") + json.decodeFromString(CreateRecord.serializer(), String(bytes, Charsets.UTF_8)) + .takeIf { it.version == 1 && it.instanceId == pairing.instanceId && it.deviceId == pairing.deviceId } + } catch (_: Exception) { + null + } + if (record != null && record.pairedAt != pairing.pairedAtMs) { + // An earlier pairing's: its unpair would have purged it, and it never replays on this one. + report("event=bot_create_other_pairing outcome=removed") + AtomicFile(file).delete() + return null + } + val create = try { + record?.create() + } catch (_: Exception) { + null + } + if (create == null) { + report("event=bot_create_unreadable outcome=set_aside") + val aside = File(file.parentFile, "create.${UUID.randomUUID()}.unreadable") + if (!file.renameTo(aside)) report("event=bot_create_set_aside outcome=failed") + return null + } + if (create.key != null && !aidenBotCreateReplayable(create.createdAtMs, nowMs())) { + // The Mac no longer keeps its key: the key goes from the disk too, so no later read + // (even with the clock turned back) replays it. The answers and request stay. + report("event=bot_create_key_expired outcome=forgotten") + val aged = create.copy(key = null) + writeCreate(pairing, CreateRecord.of(pairing, aged)) + return aged + } + return create + } + + /** + * True once [record] is synced and in place (or there is no disk: a memory-only store), false + * when it couldn't be written or the pairing was unpaired. [AtomicFile.finishWrite] reports + * neither a failed sync nor a failed rename, so both are checked here. + */ + private fun writeCreate(pairing: AidenPairingIdentity, record: CreateRecord): Boolean { + val file = createFileFor(pairing) ?: return true + if (synchronized(this) { pairing in revokedCreates }) return false + val atomic = AtomicFile(file) + var stream: java.io.FileOutputStream? = null + return try { + val data = json.encodeToString(CreateRecord.serializer(), record).toByteArray(Charsets.UTF_8) + file.parentFile?.mkdirs() + stream = atomic.startWrite() + stream.write(data) + stream.fd.sync() + atomic.finishWrite(stream) + stream = null + if (!file.isFile || File(file.path + ".new").exists()) throw java.io.IOException("not in place") + true + } catch (_: Exception) { + stream?.let { atomic.failWrite(it) } + report("event=bot_create_write outcome=failed") + false + } + } + + /** Beside the pairing's chat outboxes (named by 64-character digests, so never this name), and purged with them. */ + internal fun createFileFor(pairing: AidenPairingIdentity): File? = root?.let { + File(File(File(it, digest(pairing.instanceId)), digest(pairing.deviceId)), "create.json") + } + + // --- Disk --- + + @Serializable + private data class Record( + val id: String, + val key: String, + val text: String, + val mode: AidenStreamInputMode? = null, + val enqueuedWhileRunning: Boolean = false, + val retryOf: String? = null, + val createdAt: Long, + val submissionId: String? = null, + val failed: Boolean = false, + val frozen: Boolean = false, + val sentMode: AidenStreamInputMode? = null, + val baseline: List = emptyList(), + val connectionAttempts: Int = 0, + val keepsKey: Boolean = false + ) { + fun message(): AidenBotOutgoingMessage { + require(text.isNotBlank() && text.codePointCount(0, text.length) <= AidenBotSessionWire.MAX_MESSAGE_LENGTH) + require(id.startsWith("outgoing:") && id.length <= 80 && connectionAttempts >= 0) + return AidenBotOutgoingMessage( + id = id, + key = UUID.fromString(key), + text = text, + mode = mode, + enqueuedWhileRunning = enqueuedWhileRunning, + retryOf = retryOf, + createdAtMs = createdAt, + status = when { + failed -> AidenBotOutgoingStatus.FAILED + submissionId != null -> AidenBotOutgoingStatus.SENT + else -> AidenBotOutgoingStatus.SENDING + }, + submissionId = submissionId, + frozen = frozen, + sentMode = sentMode, + baseline = baseline.toSet(), + connectionAttempts = connectionAttempts, + keepsKey = keepsKey + ) + } + + companion object { + fun of(message: AidenBotOutgoingMessage) = Record( + id = message.id, + key = message.key.toString(), + text = message.text, + mode = message.mode, + enqueuedWhileRunning = message.enqueuedWhileRunning, + retryOf = message.retryOf, + createdAt = message.createdAtMs, + submissionId = message.submissionId.takeIf { message.status == AidenBotOutgoingStatus.SENT }, + failed = message.status == AidenBotOutgoingStatus.FAILED, + frozen = message.frozen, + sentMode = message.sentMode, + baseline = message.baseline.toList(), + connectionAttempts = message.connectionAttempts, + keepsKey = message.keepsKey + ) + } + } + + @Serializable + private data class Envelope( + val version: Int, + val instanceId: String, + val deviceId: String, + val botId: String, + val messages: List + ) + + private class Loaded(val messages: List, val changed: Boolean, val unreadable: Boolean) + + /** + * One chat's messages from disk. Each record decodes on its own, so a bad one costs only + * itself. A file this build can't read (unopenable, oversized, damaged, a newer version or + * another pairing's) is set aside beside it as `.unreadable`, never deleted; when only some + * records are bad, the file as it was is copied aside and the readable ones written back. + * One still unsent after 24 hours comes back as not sent; a taken one that old is the Mac's + * now. Nothing is dropped to fit: the size limit is checked when a message is sent. + */ + private fun load(key: AidenBotSessionKey): Loaded { + val nothing = Loaded(emptyList(), changed = false, unreadable = false) + val file = fileFor(key) ?: return nothing + val atomic = AtomicFile(file) + // AtomicFile keeps the last good copy as `.bak` until a write finishes. + if (!file.exists() && !File(file.path + ".bak").exists()) return nothing + val envelope = try { + val bytes = atomic.readFully() + // A chat never writes one this large: two full outboxes merged on a restore at most. + if (bytes.size > 2 * maxBytes) throw IllegalStateException("too large") + json.decodeFromString(Envelope.serializer(), String(bytes, Charsets.UTF_8)) + .takeIf { it.version == 1 && it.instanceId == key.instanceId && it.deviceId == key.deviceId && it.botId == key.botId } + } catch (_: Exception) { + null + } + if (envelope == null) { + report("event=bot_outbox_unreadable outcome=set_aside") + setAside(key, file, keepOriginal = false) + return Loaded(emptyList(), changed = false, unreadable = true) + } + val readable = envelope.messages.mapNotNull { element -> + try { + json.decodeFromJsonElement(Record.serializer(), element).message() + } catch (_: Exception) { + null + } + } + val lostSome = readable.size != envelope.messages.size + if (lostSome) { + report("event=bot_outbox_record_unreadable outcome=set_aside count=${envelope.messages.size - readable.size}") + setAside(key, file, keepOriginal = true) + } + val now = nowMs() + val messages = readable.mapNotNull { message -> + when { + !message.isExpired(now) -> message + message.status == AidenBotOutgoingStatus.SENT -> null + message.status == AidenBotOutgoingStatus.SENDING -> message.expired() + else -> message + } + } + return Loaded(messages, changed = lostSome || messages != readable, unreadable = lostSome) + } + + /** + * Moves (or, with [keepOriginal], copies) a file this build can't fully read out of the + * chat's way, as `..unreadable`. One that can't be moved is never written over. + */ + private fun setAside(key: AidenBotSessionKey, file: File, keepOriginal: Boolean) { + val aside = File(file.parentFile, "${file.nameWithoutExtension}.${UUID.randomUUID()}.unreadable") + val moved = try { + if (keepOriginal) { + file.copyTo(aside) + true + } else { + file.renameTo(aside) + } + } catch (_: Exception) { + false + } + if (!moved) { + report("event=bot_outbox_set_aside outcome=failed") + if (!keepOriginal) synchronized(this) { stuck += key } + return + } + // A backup left beside it belongs to the same unreadable file. + if (!keepOriginal) File(file.path + ".bak").takeIf { it.exists() }?.renameTo(File(aside.path + ".bak")) + } + + private fun encode(key: AidenBotSessionKey, records: List): ByteArray { + val envelope = Envelope(1, key.instanceId, key.deviceId, key.botId, records.map { json.encodeToJsonElement(it) }) + return json.encodeToString(Envelope.serializer(), envelope).toByteArray(Charsets.UTF_8) + } + + /** Atomic and synced before it replaces the old file, so a crash leaves the old or the new outbox, never half of one. */ + private fun write(key: AidenBotSessionKey, records: List) { + val file = fileFor(key) ?: return + if (synchronized(this) { key in stuck }) return + val atomic = AtomicFile(file) + if (records.isEmpty()) { + atomic.delete() + return + } + var stream: java.io.FileOutputStream? = null + try { + val data = encode(key, records) + file.parentFile?.mkdirs() + stream = atomic.startWrite() + stream.write(data) + atomic.finishWrite(stream) + } catch (_: Exception) { + stream?.let { atomic.failWrite(it) } + report("event=bot_outbox_write outcome=failed") + } + } + + /** Where [key]'s outbox lives on disk; null for a memory-only store. */ + internal fun fileFor(key: AidenBotSessionKey): File? = root?.let { + File(File(File(it, digest(key.instanceId)), digest(key.deviceId)), "${digest(key.botId)}.json") + } + + companion object { + const val LIFETIME_MS = 24L * 60 * 60 * 1_000 + /** Room for the Mac's and the phone's clocks under the Mac's day-long Create key ([aidenBotCreateReplayable]). */ + const val CREATE_REPLAY_MARGIN_MS = 60L * 60 * 1_000 + /** How long after it began a New Bot may still be replayed on its key. */ + const val CREATE_REPLAY_MS = LIFETIME_MS - CREATE_REPLAY_MARGIN_MS + /** The most messages one chat may hold waiting; another is refused, visibly. */ + const val MAX_MESSAGES = 20 + const val CAPACITY = 16 + const val MAX_BYTES = 4_000_000 + /** A New Bot's answers and request are far smaller; anything past this is damage. */ + private const val MAX_CREATE_BYTES = 1_000_000 + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = false } + private val defaultIo: Executor by lazy { + Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "aiden-bot-outbox").apply { isDaemon = true } } + } + + /** A pairing's time as it is stored (to the millisecond), so a relaunch reads its own pairing's record back. */ + private val AidenPairingIdentity.pairedAtMs: Long get() = pairedAt.toEpochMilli() + + private fun digest(value: String): String = + MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + } +} diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotProfileScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotProfileScreen.kt index 96c71fd42..12d0c3b23 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotProfileScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotProfileScreen.kt @@ -41,6 +41,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.draw.alpha import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -52,6 +54,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import sbtbiswas.AidenOnTheGo.R +import sbtbiswas.AidenOnTheGo.features.remote.AidenConnectionState import sbtbiswas.AidenOnTheGo.features.remote.AidenRemoteCoordinator import sbtbiswas.AidenOnTheGo.features.shared.AidenReadPresentation import sbtbiswas.AidenOnTheGo.models.* @@ -128,21 +131,51 @@ fun AidenBotProfileScreen( var showAdvanced by remember { mutableStateOf(false) } var confirmingDelete by remember { mutableStateOf(false) } var isDeleting by remember { mutableStateOf(false) } - var isSavingCharacter by remember { mutableStateOf(false) } var actionError by remember { mutableStateOf(null) } + // Name, subtitle and character edits apply at once and save in the background (R15). + val saveHost = remember(client) { coordinator.botSaveHost() } + var editor by remember(botId) { mutableStateOf(null) } + LaunchedEffect(botId, bot != null, client) { + val current = bot ?: return@LaunchedEffect + editor = coordinator.botIdentityEditor(current) + } + val editState = editor?.state?.collectAsStateWithLifecycle()?.value val avatarModel = remember(botId) { AidenBotGeneratedAvatarModel(coordinator, botId, coordinator.botCache) } val photoPhase by avatarModel.phase.collectAsStateWithLifecycle() val photoError by avatarModel.errorMessage.collectAsStateWithLifecycle() val canDelete = aidenBotDeleteAvailable(serverInfo, botDeleter) - fun accept(detail: AidenBotDetail) { + /** + * A Bot for the page and every screen. [confirmed] is the Mac's answer, kept on disk; an + * Advanced Save on its way (or its rollback) is only shown. Pinned either way: a Bot from + * another pairing's save never lands in this one's cache. + */ + fun accept(detail: AidenBotDetail, confirmed: Boolean) { bot = detail nameText = detail.name subtitleText = detail.purpose - coordinator.botCache.putBotDetail(detail) + if (confirmed) saveHost.cache(detail) else saveHost.show(detail) + editor?.adopt(detail) onBotMutated() } + // What the editor shows (an edit, a rollback, the Mac's Bot) is what the page shows. Text the + // person hasn't touched since follows it; what they are typing stays. + LaunchedEffect(editState?.bot) { + val shown = editState?.bot ?: return@LaunchedEffect + val before = bot + if (before == null || nameText == before.name) nameText = shown.name + if (before == null || subtitleText == before.purpose) subtitleText = shown.purpose + bot = shown + } + // A save the Mac confirmed: the list behind rereads. + var wasSaving by remember { mutableStateOf(false) } + LaunchedEffect(editState?.isSaving) { + val saving = editState?.isSaving == true + if (wasSaving && !saving && editState?.failure == null) onBotMutated() + wasSaving = saving + } + LaunchedEffect(client, botId, connectionState, readAttempt) { val cl = client ?: run { isLoading = false @@ -155,11 +188,14 @@ fun AidenBotProfileScreen( try { val fresh = cl.bot(botId) if (!coordinator.holdsReadAuthority(cl, requestInstance)) return@LaunchedEffect + loadFailed = false + // An edit still saving shows over the read; it settles on the Mac's own answer. + if (editor?.isBusy == true) return@LaunchedEffect bot = fresh nameText = fresh.name subtitleText = fresh.purpose - coordinator.botCache.putBotDetail(fresh) - loadFailed = false + saveHost.cache(fresh) + editor?.adopt(fresh) } catch (e: CancellationException) { throw e } catch (_: Exception) { @@ -188,6 +224,7 @@ fun AidenBotProfileScreen( onPauseOrDispose { job?.cancel() } } + // Each edit shows at once and saves when the Mac is reachable; a failed one goes back (R15). fun saveText() { val current = bot ?: return val patch = aidenBotIdentityTextPatch(current, nameText, subtitleText) @@ -195,45 +232,17 @@ fun AidenBotProfileScreen( if (nameText.isBlank()) nameText = current.name return } - val cl = client ?: run { - actionError = resources.getString(R.string.bot_profile_connect_to_save) - return - } - scope.launch { - try { - accept(cl.updateBotIdentity(current.id, current.revision, patch)) - actionError = null - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - actionError = resources.getString(R.string.bot_save_failed) - } - } + val ed = editor ?: return + actionError = null + saveHost.launch { ed.edit(patch) } } fun saveCharacter(recipe: AidenBotAvatarRecipe) { val current = bot ?: return val patch = aidenBotCharacterPatch(current, recipe) ?: return - val cl = client ?: run { - actionError = resources.getString(R.string.bot_profile_connect_to_save) - return - } - // Show the new look right away; roll back if the Mac says no. - bot = current.copy(avatar = current.avatar.copy(semantic = AidenBotSemanticAvatar.Recipe(recipe))) - scope.launch { - isSavingCharacter = true - try { - accept(cl.updateBotIdentity(current.id, current.revision, patch)) - actionError = null - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - bot = current - actionError = resources.getString(R.string.bot_profile_character_save_failed) - } finally { - isSavingCharacter = false - } - } + val ed = editor ?: return + actionError = null + saveHost.launch { ed.edit(patch) } } val photoPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> @@ -253,7 +262,7 @@ fun AidenBotProfileScreen( } if (avatarModel.hasCandidate) { avatarModel.uploadAvatar(current.id, aidenBotAvatarExpectedRevision(current)) { updated -> - scope.launch { accept(updated) } + scope.launch { accept(updated, confirmed = true) } } } } @@ -285,7 +294,7 @@ fun AidenBotProfileScreen( ) if (canDelete) { DropdownMenuItem( - text = { Text(stringResource(R.string.bot_delete_confirm), color = palette.danger) }, + text = { Text(stringResource(R.string.bot_delete_confirm), color = aidenBotErrorTextColor()) }, leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null, tint = palette.danger) }, onClick = { showMenu = false @@ -304,7 +313,8 @@ fun AidenBotProfileScreen( val presentation = AidenReadPresentation.of( hasContent = current != null, isFetching = isLoading, - hasSettled = loadFailed || client == null, + // Connecting is not settled: skeletons, never a "connect" screen, until it fails (R14). + hasSettled = loadFailed || connectionState == AidenConnectionState.NEEDS_PAIRING, failed = loadFailed ) when { @@ -344,6 +354,16 @@ fun AidenBotProfileScreen( name = current.name, size = 120.dp ) + // A photo on its way shimmers over the face, never a spinner (R15). + if (photoPhase == AidenBotGeneratedAvatarPhase.UPLOADING || photoPhase == AidenBotGeneratedAvatarPhase.REVERTING) { + val saving = stringResource(R.string.bot_avatar_saving) + AidenSkeletonBlock( + width = 120.dp, + height = 120.dp, + shape = CircleShape, + modifier = Modifier.alpha(0.7f).semantics { stateDescription = saving } + ) + } } Surface( onClick = { showPhotoMenu = true }, @@ -375,7 +395,7 @@ fun AidenBotProfileScreen( showPhotoMenu = false scope.launch { avatarModel.deleteAvatar(current.id, aidenBotAvatarExpectedRevision(current)) { updated -> - scope.launch { accept(updated) } + scope.launch { accept(updated, confirmed = true) } } } } @@ -383,10 +403,7 @@ fun AidenBotProfileScreen( } } } - if (photoPhase == AidenBotGeneratedAvatarPhase.UPLOADING || photoPhase == AidenBotGeneratedAvatarPhase.REVERTING) { - LinearProgressIndicator(color = palette.accent, modifier = Modifier.width(120.dp)) - } - photoError?.let { Text(it, color = palette.danger, style = MaterialTheme.typography.bodySmall) } + photoError?.let { Text(it, color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall) } Surface(color = palette.raised, shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth()) { Column { @@ -410,10 +427,10 @@ fun AidenBotProfileScreen( } } + // Never held back while a save runs: each pick shows at once and saves after the last. AidenBotCharacterCard( recipe = AidenBotCharacter.recipe(current.avatar.semantic), - onChange = ::saveCharacter, - enabled = !isSavingCharacter + onChange = ::saveCharacter ) Surface( @@ -473,7 +490,9 @@ fun AidenBotProfileScreen( ) } - actionError?.let { Text(it, color = palette.danger, style = MaterialTheme.typography.bodySmall) } + (editState?.failure?.let { stringResource(aidenBotSaveFailureText(it)) } ?: actionError)?.let { + Text(it, color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall) + } } } } @@ -484,10 +503,19 @@ fun AidenBotProfileScreen( bot = current, client = client, onDismiss = { showAdvanced = false }, + // Save applies at once (R15): the optimistic Bot now, the Mac's when it answers. onSaved = { - accept(it) + accept(it, confirmed = false) showAdvanced = false - } + }, + saveHost = saveHost, + // The Mac's Bot is already kept by the save; a rollback is only shown. + onSaveSettled = { accept(it, confirmed = false) }, + onSaveFailed = coordinator::presentError, + chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true, cachedSupport = coordinator.cachedBotActivitySupported), + chatActivityHost = remember(client) { coordinator.botChatActivityHost() }, + // The baseline only: unsaved name or subtitle edits stay, and the host has cached the pick. + onChatActivitySaved = { bot = it } ) } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotRoutines.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotRoutines.kt index f21602fbc..4eab83fe8 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotRoutines.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotRoutines.kt @@ -300,7 +300,7 @@ fun AidenBotRoutinesSection(botId: String, client: AidenRemoteClient?, offersSug } } } - error?.let { Text(it, color = palette.danger, style = MaterialTheme.typography.bodySmall) } + error?.let { Text(it, color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall) } } val cl = client @@ -452,7 +452,7 @@ private fun AidenBotRoutineEditorDialog( } ) } - error?.let { Text(it, color = palette.danger, style = MaterialTheme.typography.bodySmall) } + error?.let { Text(it, color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall) } } }, confirmButton = { diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSemanticAvatarView.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSemanticAvatarView.kt index db2324432..54421f6ba 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSemanticAvatarView.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSemanticAvatarView.kt @@ -4,6 +4,10 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.geometry.CornerRadius @@ -17,7 +21,10 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import sbtbiswas.AidenOnTheGo.R +import sbtbiswas.AidenOnTheGo.features.chat.aidenPowerSaveMode import sbtbiswas.AidenOnTheGo.models.* +import sbtbiswas.AidenOnTheGo.ui.theme.aidenFrameDue +import sbtbiswas.AidenOnTheGo.ui.theme.aidenReduceMotion /** * What a Bot's mark looks like: a colour and a shape. Every Bot shares one fixed eye @@ -68,12 +75,38 @@ fun AidenBotSemanticAvatarView( avatar: AidenBotSemanticAvatar, name: String = "", size: Dp = 48.dp, + /** While its Bot works the face glances (R8); Reduce Motion, battery saver and the background hold it still. */ + working: Boolean = false, modifier: Modifier = Modifier ) { val presentation = aidenBotAvatarPresentation(avatar) val fill = AidenBotAvatarColors.swatch(presentation.color) val avatarLabel = if (name.isNotEmpty()) stringResource(R.string.bots_avatar_cd, name) else null + val glancing = aidenBotFaceMotion( + working = working, + photo = false, + reduceMotion = aidenReduceMotion(), + powerSave = working && aidenPowerSaveMode(), + resumed = aidenBotIsResumed() + ).glances + val clockMs = remember { mutableLongStateOf(0L) } + LaunchedEffect(glancing) { + if (!glancing) { + clockMs.longValue = 0L + return@LaunchedEffect + } + var startMs = Long.MIN_VALUE + while (true) { + withFrameMillis { frameMs -> + if (startMs == Long.MIN_VALUE) startMs = frameMs + // About 30 frames a second: a glance needs no more, and a list of faces stays cheap. + val elapsed = frameMs - startMs + if (aidenFrameDue(clockMs.longValue, elapsed)) clockMs.longValue = elapsed + } + } + } + Box( modifier = modifier .size(size) @@ -85,17 +118,21 @@ fun AidenBotSemanticAvatarView( val shapePath = aidenBotShapePath(presentation.shape, w, h) drawPath(path = shapePath, color = fill) - // The one fixed eye mark: two slanted pills in the shared face colour. + // The one fixed eye mark: two slanted pills in the shared face colour. The clock is + // read only here, in the draw phase, as in AidenActivityMark. + val glance = if (glancing) aidenBotFaceGlance(clockMs.longValue) else AidenBotFaceGlance(0f, 1f) val eyeWidth = w * 0.11f val eyeHeight = h * 0.24f val eyeTop = h * 0.42f - listOf(w * 0.40f, w * 0.58f).forEach { left -> + val shownHeight = eyeHeight * glance.blink + listOf(w * 0.40f, w * 0.58f).forEach { base -> + val left = base + glance.dxFraction * w rotate(degrees = 12f, pivot = Offset(left + eyeWidth / 2f, eyeTop + eyeHeight / 2f)) { drawRoundRect( color = AidenBotAvatarColors.face, - topLeft = Offset(left, eyeTop), - size = Size(eyeWidth, eyeHeight), - cornerRadius = CornerRadius(eyeWidth / 2f, eyeWidth / 2f) + topLeft = Offset(left, eyeTop + (eyeHeight - shownHeight) / 2f), + size = Size(eyeWidth, shownHeight), + cornerRadius = CornerRadius(eyeWidth / 2f, minOf(eyeWidth, shownHeight) / 2f) ) } } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionController.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionController.kt index eb6c2c3c1..2c8ef9017 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionController.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionController.kt @@ -11,6 +11,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import androidx.annotation.StringRes +import sbtbiswas.AidenOnTheGo.R import sbtbiswas.AidenOnTheGo.models.* import sbtbiswas.AidenOnTheGo.networking.AidenRemoteClient import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteClientException @@ -21,7 +24,8 @@ import java.util.UUID interface AidenBotSessionTransport { suspend fun session(botId: String): AidenBotSession fun events(botId: String): Flow - suspend fun send(botId: String, text: String, key: UUID): AidenBotSessionSendResponse + /** `whenBusy` is Steer or Queue while the Bot works; only a Mac announcing `bot-activity-v1` takes it. */ + suspend fun send(botId: String, text: String, key: UUID, whenBusy: AidenStreamInputMode? = null): AidenBotSessionSendResponse suspend fun resume(botId: String, key: UUID): AidenBotSessionStateView suspend fun dismiss(botId: String, key: UUID): AidenBotSessionStateView suspend fun stop(botId: String, key: UUID): AidenBotSessionStateView @@ -43,10 +47,16 @@ interface AidenBotSessionTransport { ): AidenBotRoutineProposalRespondResult } +/** + * The Remote routes. A message goes out exactly as the controller froze it at its first attempt: + * the controller decides `whenBusy` once (only for a Mac offering `bot-activity-v1`), so a retry + * under the same Idempotency-Key is byte-identical even if the Mac's announcement changed since. + */ class AidenRemoteBotSessionTransport(private val client: AidenRemoteClient) : AidenBotSessionTransport { override suspend fun session(botId: String) = client.botSession(botId) override fun events(botId: String) = client.botSessionEvents(botId) - override suspend fun send(botId: String, text: String, key: UUID) = client.sendBotMessage(botId, text, key) + override suspend fun send(botId: String, text: String, key: UUID, whenBusy: AidenStreamInputMode?) = + client.sendBotMessage(botId, text, key, whenBusy, supportsBotActivity = whenBusy != null) override suspend fun resume(botId: String, key: UUID) = client.resumeBotSession(botId, key) override suspend fun dismiss(botId: String, key: UUID) = client.dismissBotSession(botId, key) override suspend fun stop(botId: String, key: UUID) = client.stopBotSession(botId, key) @@ -164,6 +174,11 @@ fun aidenApplyBotSessionEvent(current: AidenBotSession?, event: AidenBotSessionE AidenBotSessionEventOutcome.Applied(current.copy(seq = event.seq, question = payload.question)) is AidenBotSessionEventPayload.Approval -> AidenBotSessionEventOutcome.Applied(current.copy(seq = event.seq, approval = payload.approval)) + // Revision 30: the frame carries the whole new value, so it replaces the stored one. + is AidenBotSessionEventPayload.Activity -> + AidenBotSessionEventOutcome.Applied(current.copy(seq = event.seq, activity = payload.activity)) + is AidenBotSessionEventPayload.Queued -> + AidenBotSessionEventOutcome.Applied(current.copy(seq = event.seq, queued = payload.queued)) AidenBotSessionEventPayload.Closed -> AidenBotSessionEventOutcome.Reconnect is AidenBotSessionEventPayload.Snapshot -> AidenBotSessionEventOutcome.Applied(payload.session) } @@ -201,37 +216,141 @@ fun aidenBotMacAnswered(error: Exception): Boolean = error is AidenRemoteClientE /** Where a connect card's action stands on this phone. */ enum class AidenBotConnectRequestPhase { IDLE, SENDING, SENT, FAILED } +/** Stop or Resume, from the tap until the Mac's own word stands for it (R14, R18). */ +enum class AidenBotTurnIntent { NONE, REQUESTED, ACKNOWLEDGED } + +/** + * A notice about this phone's unsent messages, in the copy shared with iOS (`strings.xml`). + * Unlike an action's error it stays until the person sends again or dismisses it. + */ +enum class AidenBotSessionNotice(@param:StringRes val text: Int) { + /** "That message wasn’t sent. Please try again.": access ended with messages unsent. */ + ACCESS_LOST(R.string.bot_session_access_lost), + /** "Too many messages are waiting. Send or remove some, then try again." */ + OUTBOX_FULL(R.string.bot_session_outbox_full), + /** "Some unsent messages couldn’t be restored.": a previous launch's file was set aside (FR-I2). */ + OUTBOX_UNRESTORABLE(R.string.bot_session_outbox_unrestorable) +} + +/** A Bot session request that got no answer within its limit (FR-I3): it may have reached the Mac. */ +class AidenBotSessionTimeout : java.io.IOException("The Mac didn’t answer in time.") + +/** True when a message request may have reached the Mac, so the next attempt must reuse its key. */ +fun aidenBotMessageFailureIsAmbiguous(error: Exception): Boolean = when (error) { + is AidenRemoteClientException.Server -> error.statusCode >= 500 || error.statusCode == 408 || error.statusCode == 429 || + // The Mac is still working on the first attempt: its outcome is unknown. + (error.statusCode == 409 && error.body.code == AidenRemoteErrorCode.IDEMPOTENCY_IN_FLIGHT) + is AidenRemoteClientException.UnexpectedStatus -> error.statusCode >= 500 || error.statusCode == 408 || error.statusCode == 429 + is AidenRemoteClientException.MissingCredential, + is AidenRemoteClientException.MissingTrustConfiguration, + is AidenRemoteClientException.InstallationChanged, + is AidenRemoteClientException.InvalidEndpoint -> false + // A dropped connection or an unreadable answer: the Mac may have taken it. + else -> true +} + +/** 401 or 403, or a pairing that's gone: this phone no longer reaches this Bot. */ +fun aidenBotLostAccess(error: Exception): Boolean = when (error) { + is AidenRemoteClientException.Server -> error.isCredentialRevoked + is AidenRemoteClientException.UnexpectedStatus -> error.statusCode == 401 || error.statusCode == 403 + is AidenRemoteClientException.MissingCredential, is AidenRemoteClientException.InstallationChanged -> true + else -> false +} + data class AidenBotSessionUiState( val session: AidenBotSession? = null, val isLoading: Boolean = true, val loadFailed: Boolean = false, /** The Mac no longer has this Bot (deleted elsewhere): the feed stops. */ val botMissing: Boolean = false, + /** The Mac refused this phone (401/403, or the pairing is gone): retrying can't help. */ + val accessLost: Boolean = false, + /** A message request is on its way to the Mac. The composer doesn't wait for it (R14). */ val isSending: Boolean = false, + /** A Resume request is in flight. */ val isResuming: Boolean = false, val isDismissing: Boolean = false, - val isStopping: Boolean = false, val isAnsweringQuestion: Boolean = false, val isRespondingToApproval: Boolean = false, val actionError: String? = null, + /** A notice about unsent messages; shown in place of [actionError] until dismissed or the next send. */ + val notice: AidenBotSessionNotice? = null, val connectRequests: Map = emptyMap(), /** Routine proposal answers in flight or failed, by proposal id. A settled card has none. */ val proposalResponses: Map = emptyMap(), /** The Bot's state from the home list, used until the session loads. */ - val knownState: AidenBotSessionState? = null + val knownState: AidenBotSessionState? = null, + /** Revision 30: the Mac announces `bot-activity-v1` and this phone holds `bot:activity`. */ + val activitySupported: Boolean = false, + /** The Bot's Chat activity wording, from its detail. */ + val wording: AidenBotChatActivityWording = AidenBotChatActivityWording.FOLLOWS, + /** The session feed dropped and is reconnecting. */ + val streamDown: Boolean = false, + /** A snapshot from the Mac arrived; until then the chat shows its cached transcript or skeletons. */ + val hasLoaded: Boolean = false, + /** What this chat last showed in this run of the app, until the Mac's snapshot replaces it. */ + val cachedEntries: List = emptyList(), + /** Messages sent from this phone that the Mac's transcript doesn't show yet, oldest first (R14). */ + val outbox: List = emptyList(), + /** Stop was tapped: "Stopping" until the Mac's word stands for it or the turn ends. */ + val stopIntent: AidenBotTurnIntent = AidenBotTurnIntent.NONE, + /** Resume was tapped: "Picking up where it left off" until the Mac says what the run is doing. */ + val resumeIntent: AidenBotTurnIntent = AidenBotTurnIntent.NONE ) { val state: AidenBotSessionState? get() = session?.state ?: knownState val needsModel: Boolean get() = state == AidenBotSessionState.NEEDS_MODEL val isRunning: Boolean get() = state == AidenBotSessionState.RUNNING val isInterrupted: Boolean get() = session?.interrupted == true + val isAccessBlocked: Boolean get() = session?.blocked == AidenBotSessionBlock.ACCESS_CHANGED + + /** The transcript to draw: the Mac's, or the cached one while connecting. */ + val entries: List get() = session?.entries ?: cachedEntries + /** Nothing to show yet: skeleton bubbles, never a spinner. */ + val showsSkeleton: Boolean get() = !hasLoaded && entries.isEmpty() + /** Not connected yet, or reconnecting: a quiet "Connecting…" hint, never a blocking screen. */ + val isConnecting: Boolean get() = !hasLoaded || streamDown + /** Connected well enough to send now; otherwise messages wait in the outbox. */ + val isConnected: Boolean get() = hasLoaded && !streamDown + + /** The composer takes a message unless the Mac said this Bot can't; before the first snapshot it waits in the outbox. */ val canSend: Boolean - get() = !needsModel && !isSending && !botMissing && state != AidenBotSessionState.UNAVAILABLE && session != null + get() = !needsModel && !botMissing && state != AidenBotSessionState.UNAVAILABLE + + /** "Stopping" shows. */ + val isStopping: Boolean get() = stopIntent != AidenBotTurnIntent.NONE && isRunning + /** "Picking up where it left off" shows. */ + val isPickingUp: Boolean get() = resumeIntent != AidenBotTurnIntent.NONE && isRunning + + /** The person's messages the Mac holds for the running turn (revision 30). */ + val queued: List get() = session?.queued.orEmpty() + + /** The outgoing messages to draw after the transcript and the Mac's Queued bubbles. */ + val outgoing: List get() = outbox.filter { !it.echoed && !isShownAsQueued(it) } + + private fun isShownAsQueued(message: AidenBotOutgoingMessage): Boolean = + message.queuedId != null && queued.any { it.submissionId == message.queuedId } + + /** A message was sent and the turn hasn't taken it yet. */ + val isAwaitingTurn: Boolean get() = outbox.any { it.status == AidenBotOutgoingStatus.SENDING } + + fun look(message: AidenBotOutgoingMessage): AidenBotOutgoingLook = when { + message.status == AidenBotOutgoingStatus.FAILED -> AidenBotOutgoingLook.Failed + // It left the Mac's queue: the turn took it in, and its entry is on the way. + message.status == AidenBotOutgoingStatus.SENT && message.queuedId != null -> AidenBotOutgoingLook.Sent + activitySupported && isRunning && message.enqueuedWhileRunning -> + AidenBotOutgoingLook.Queued(message.mode ?: AidenStreamInputMode.QUEUE) + message.status == AidenBotOutgoingStatus.SENDING -> AidenBotOutgoingLook.Sending + else -> AidenBotOutgoingLook.Sent + } + + /** Steer and Queue while the Bot works need `bot-activity-v1`. */ + val offersBusyModes: Boolean get() = activitySupported && isRunning /** Stop stays in the top bar while a turn runs, including while it waits on a question or approval card. */ val canStopTurn: Boolean get() = isRunning || ((session?.question != null || session?.approval != null) && !isInterrupted) - /** The failed turn Retry resends: only the newest entry, only with text, only while sending is allowed. */ + /** The failed turn Retry resends: only the newest entry, only with text, while sending is allowed and no Retry of it is on its way. */ val retryableFailedTurn: AidenBotSessionEntry.FailedTurn? get() { val failed = session?.entries?.lastOrNull() as? AidenBotSessionEntry.FailedTurn ?: return null @@ -239,49 +358,230 @@ data class AidenBotSessionUiState( if (!canSend || text.isBlank() || text.codePointCount(0, text.length) > AidenBotSessionWire.MAX_MESSAGE_LENGTH ) return null + if (outbox.any { it.retryOf == failed.id && it.status != AidenBotOutgoingStatus.FAILED }) return null return failed } } +/** What the Bot chat's status line shows (spec §12). */ +data class AidenBotStatusLineState(val display: AidenBotActivityDisplay, val elapsed: String?) + +/** Phases with no tool at work: while the reply's words stream in them, the words are the signal (§5.2). */ +private val AIDEN_BOT_REPLY_PHASES = setOf("writing", "thinking") + +/** + * The status line now, or null when it is hidden. It shows on a Mac offering + * `bot-activity-v1` while a turn runs, and at once after a send ("Getting ready"). It hides only + * while the reply's text streams with no tool at work, as on the Mac (R7). + */ +fun aidenBotStatusLine(ui: AidenBotSessionUiState, nowMs: Long): AidenBotStatusLineState? { + if (!ui.activitySupported || !(ui.isRunning || ui.isAwaitingTurn)) return null + fun line(phase: String, elapsed: String? = null) = + AidenBotStatusLineState(aidenBotActivityDisplay(phase, ui.wording, null), elapsed) + // A message just sent says "Getting ready" before the Mac's state catches up. + if (!ui.isRunning) return line(if (ui.streamDown) "reconnecting" else "preparing") + val session = ui.session + val activity = session?.activity + val elapsed = activity?.let { aidenBotElapsedLabel(it.startedAt, nowMs) } + if (ui.streamDown) return line("reconnecting", elapsed) + if (ui.isStopping) return line("stopping", elapsed) + if (ui.isPickingUp) return line("resuming", elapsed) + // Narration can outlast its words into a tool call, so a tool at work keeps the line. + val replyStreaming = !session?.partial.isNullOrBlank() + if (replyStreaming && (activity == null || activity.phase in AIDEN_BOT_REPLY_PHASES)) return null + // Sent, and the first activity frame is on its way. + if (activity == null) return line("preparing") + return AidenBotStatusLineState(aidenBotActivityDisplay(activity.phase, ui.wording, activity.app), elapsed) +} + /** * Drives one durable Bot chat: loads `GET /bots/{id}/session`, follows the event feed with the * `(epoch, seq)` rule, and sends turns and controls with stable idempotency keys. + * + * Every action applies at once and reconciles with the Mac later (R14, R18). A message shows + * immediately and waits in an outbox until the Mac is reachable; it is settled by the Mac's + * entry with its `submissionId` (text only where the entry has none), never removed while its + * request is unsettled, and never overtaken by a later one. With [sessionKey] and [store] the + * outbox outlives the screen and the process, and the chat opens on its last transcript. */ class AidenBotSessionController( val botId: String, private val transport: AidenBotSessionTransport, private val scope: CoroutineScope, knownState: AidenBotSessionState? = null, + activitySupported: Boolean = false, private val keys: AidenBotActionKeys = AidenBotActionKeys(), - private val reconnectDelayMillis: Long = 1_500 + private val reconnectDelayMillis: Long = 1_500, + private val sessionKey: AidenBotSessionKey? = null, + private val store: AidenBotSessionStore? = null, + /** The waits between retries of a message whose request may not have arrived, while connected. */ + private val retryDelaysMillis: List = listOf(1_000, 4_000), + /** A clean close reconnects at once; only one that stays down this long says "Reconnecting". */ + private val reconnectingDebounceMillis: Long = 2_000, + /** Every request to the Mac gets an answer within this, or counts as one that may have arrived (FR-I3). */ + private val requestLimitMillis: Long = REQUEST_LIMIT_MILLIS, + /** How long a taken message that nothing on the Mac shows stays before it goes (FR-m5). */ + private val unheldGraceMillis: Long = UNHELD_GRACE_MILLIS, + private val nowMs: () -> Long = System::currentTimeMillis ) { - private val _state = MutableStateFlow(AidenBotSessionUiState(knownState = knownState)) - val state: StateFlow = _state.asStateFlow() + private val _state: MutableStateFlow + val state: StateFlow + private var feedJob: Job? = null - private var pendingSendText: String? = null + private var flushJob: Job? = null + private var backoffJob: Job? = null + private var downJob: Job? = null + private var restoreJob: Job? = null + /** Grace timers for taken messages nothing on the Mac shows, by key; and those whose grace ran out. */ + private val unheldTimers = mutableMapOf() + private val unheldExpired = mutableSetOf() + /** What a previous launch left on disk has been read (or there is nothing to read): deliveries wait for it. */ + private var restored: Boolean + /** Bumped by every access-loss purge: a disk restore that started before it is stale and is dropped, not merged. */ + private var purgeGeneration = 0 + private var released = false + private var isFlushing = false + private val inFlight = mutableSetOf() + private var pendingAnswer: Pair? = null + private var pendingApprovalDecision: Pair? = null + /** How many times the Mac has written each field, so a failed action rolls back only what nothing newer replaced. */ + private var stateWrites = 0 + private var questionWrites = 0 + private var approvalWrites = 0 + /** Transcript entries that already stand for an outgoing message, for the life of an epoch. */ + private val claimedEntries = mutableSetOf() + private var claimsEpoch: String? = null + /** The run a Stop was pressed in, by its `startedAt`. */ + private var stopStartedAt: Long? = null + + init { + val cached = sessionKey?.let { store?.transcript(it) }.orEmpty() + // Memory only: a chat already read in this run of the app opens on its messages at once; + // otherwise [start] reads the disk off the main thread. + val held = sessionKey?.let { store?.adoptOutbox(it, this) } + restored = sessionKey == null || store == null || held != null + val now = nowMs() + val adopted = held.orEmpty().map { it.adopted(now) } + _state = MutableStateFlow( + AidenBotSessionUiState( + knownState = knownState, + activitySupported = activitySupported, + cachedEntries = cached, + outbox = adopted + ) + ) + state = _state.asStateFlow() + if (held != null && adopted != held && sessionKey != null) store?.storeOutbox(sessionKey, this, adopted) + } + + fun setActivitySupported(supported: Boolean) { + _state.update { it.copy(activitySupported = supported) } + } - /** Starts loading and following the session; safe to call once per screen. */ + fun setWording(wording: AidenBotChatActivityWording) { + _state.update { it.copy(wording = wording) } + } + + /** Starts restoring the outbox, loading and following the session; safe to call once per screen. */ fun start() { + released = false + ensureRestored() if (feedJob?.isActive == true) return feedJob = scope.launch { follow() } } + /** + * The chat closed: the feed, retry timers and any delivery under way stop, and unsent + * messages stay, key and body, for the next visit. + */ fun stopFollowing() { + released = true feedJob?.cancel() feedJob = null + flushJob?.cancel() + backoffJob?.cancel() + downJob?.cancel() + restoreJob?.cancel() + unheldTimers.values.forEach { it.cancel() } + unheldTimers.clear() + } + + /** The notice was read. */ + fun dismissNotice() { + _state.update { it.copy(notice = null) } } - /** Reloads `GET /bots/{id}/session`; returns false when it could not. */ + /** [block], or [AidenBotSessionTimeout] once it has run past [requestLimitMillis]. */ + private suspend fun bounded(block: suspend () -> T): T = + withTimeoutOrNull(requestLimitMillis) { block() } ?: throw AidenBotSessionTimeout() + + /** + * Reads what a previous launch left on disk, once, on the store's io thread. Those messages + * are older than anything sent since, so they go first, and nothing is delivered before them. + */ + private fun ensureRestored() { + if (restored || restoreJob?.isActive == true) return + val key = sessionKey ?: return + val store = store ?: return + val generation = purgeGeneration + restoreJob = scope.launch { + val restore = try { + store.restoreOutbox(key, this@AidenBotSessionController) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + // The read itself failed: nothing came back, and the person hears so. + AidenBotOutboxRestore(emptyList(), unreadable = true) + } + // Access ended while the disk was read: what came back is from before the purge. Merging it + // would bring the purged text back into the outbox and onto disk, so it is dropped. + if (generation != purgeGeneration) { + wakeOutbox() + return@launch + } + val now = nowMs() + val stored = restore.messages + val known = stored.mapTo(HashSet()) { it.key } + restored = true + val (merged, overflow) = capped(stored.map { it.adopted(now) } + _state.value.outbox.filterNot { it.key in known }) + setOutbox(merged, force = true) + when { + restore.unreadable -> _state.update { it.copy(notice = AidenBotSessionNotice.OUTBOX_UNRESTORABLE) } + overflow -> _state.update { it.copy(notice = AidenBotSessionNotice.OUTBOX_FULL) } + } + reconcileOutbox(snapshot = false) + wakeOutbox() + } + } + + /** Reloads `GET /bots/{id}/session`; returns false when it could not. One older than frames already shown is dropped. */ suspend fun refetch(): Boolean { + ensureRestored() + return load(authoritative = false) + } + + private suspend fun load(authoritative: Boolean): Boolean { + // Try again shows at once that it is trying: "Connecting…" and shimmering skeletons. + if (_state.value.session == null) _state.update { it.copy(isLoading = true, loadFailed = false) } return try { - val session = transport.session(botId) - _state.update { it.copy(session = session, isLoading = false, loadFailed = false) } + val session = bounded { transport.session(botId) } + val current = _state.value.session + if (!authoritative && current != null && session.epoch == current.epoch && session.seq < current.seq) { + _state.update { it.copy(isLoading = false, loadFailed = false) } + } else { + applySnapshot(session) + } true } catch (error: CancellationException) { throw error } catch (error: Exception) { val missing = (error as? AidenRemoteClientException.Server)?.statusCode == 404 - _state.update { it.copy(isLoading = false, loadFailed = it.session == null, botMissing = it.botMissing || missing) } + val lost = aidenBotLostAccess(error) + if (lost) purgeForAccessLoss() + _state.update { + it.copy(isLoading = false, loadFailed = it.session == null, botMissing = it.botMissing || missing, accessLost = it.accessLost || lost) + } + if (missing) failWaiting() false } } @@ -290,147 +590,699 @@ class AidenBotSessionController( suspend fun handle(event: AidenBotSessionEvent): Boolean { return when (val outcome = aidenApplyBotSessionEvent(_state.value.session, event)) { is AidenBotSessionEventOutcome.Applied -> { - _state.update { it.copy(session = outcome.session, isLoading = false, loadFailed = false) } - outcome.session.entries.filterIsInstance() - .filter { it.status != AidenBotConnectCardStatus.PENDING } - .forEach { card -> _state.update { it.copy(connectRequests = it.connectRequests - card.pluginId) } } - // A proposal settled on the Mac or another phone needs no answer from here. - outcome.session.entries.filterIsInstance() - .filter { it.status != AidenBotRoutineProposalStatus.PENDING } - .forEach { card -> _state.update { it.copy(proposalResponses = it.proposalResponses - card.proposalId) } } + apply(outcome.session, event.payload) true } AidenBotSessionEventOutcome.Ignored -> true // Keep showing what we have; a failed refetch reopens the feed, whose first // frame is a fresh snapshot. - AidenBotSessionEventOutcome.Refetch -> refetch() + AidenBotSessionEventOutcome.Refetch -> load(authoritative = true) AidenBotSessionEventOutcome.Reconnect -> false } } + private fun apply(session: AidenBotSession, payload: AidenBotSessionEventPayload) { + if (payload is AidenBotSessionEventPayload.Snapshot) { + applySnapshot(session) + return + } + val previous = _state.value.session + _state.update { it.copy(session = session, isLoading = false, loadFailed = false) } + settleCards(session) + when (payload) { + is AidenBotSessionEventPayload.State -> afterStateWrite(previous) + is AidenBotSessionEventPayload.Question -> questionWrites += 1 + is AidenBotSessionEventPayload.Approval -> approvalWrites += 1 + is AidenBotSessionEventPayload.Activity -> activityArrived(payload.activity) + is AidenBotSessionEventPayload.Entry -> storeTranscript() + else -> Unit + } + reconcileOutbox(snapshot = false) + scheduleFlush() + } + + /** A snapshot replaces everything, and means the Mac is reachable: the outbox goes out. */ + private fun applySnapshot(session: AidenBotSession) { + val previous = _state.value.session + _state.update { it.copy(session = session, isLoading = false, loadFailed = false, hasLoaded = true, accessLost = false) } + settleCards(session) + questionWrites += 1 + approvalWrites += 1 + if (session.activity != previous?.activity) activityArrived(session.activity) + afterStateWrite(previous) + storeTranscript() + reconcileOutbox(snapshot = true) + wakeOutbox() + } + + private fun settleCards(session: AidenBotSession) { + session.entries.filterIsInstance() + .filter { it.status != AidenBotConnectCardStatus.PENDING } + .forEach { card -> _state.update { it.copy(connectRequests = it.connectRequests - card.pluginId) } } + // A proposal settled on the Mac or another phone needs no answer from here. + session.entries.filterIsInstance() + .filter { it.status != AidenBotRoutineProposalStatus.PENDING } + .forEach { card -> _state.update { it.copy(proposalResponses = it.proposalResponses - card.proposalId) } } + } + + /** + * The Mac wrote the state. A turn that is no longer running takes its Stop, Resume and + * activity with it, so the next turn never starts at "Stopping" or on a stale phase. A Bot + * whose access changed loses what waited to go to it. + */ + private fun afterStateWrite(previous: AidenBotSession?) { + stateWrites += 1 + _state.update { current -> + if (current.isRunning) return@update current + val session = current.session + current.copy( + session = if (session?.activity != null) session.copy(activity = null) else session, + stopIntent = AidenBotTurnIntent.NONE, + resumeIntent = AidenBotTurnIntent.NONE + ) + } + val blocked = _state.value.session?.blocked == AidenBotSessionBlock.ACCESS_CHANGED + if (blocked && previous?.blocked != AidenBotSessionBlock.ACCESS_CHANGED) purgeForAccessLoss() + } + + /** + * The Mac's own word now stands for a Stop or Resume it has heard, and a new run (another + * `startedAt`, or a first activity after a Stop pressed before any) has nothing to stop. An + * activity after a Resume tap says what the resumed run is doing; a cleared one says nothing. + */ + private fun activityArrived(next: AidenBotSessionActivity?) { + val stopRun = stopStartedAt + _state.update { current -> + val clearStop = current.stopIntent == AidenBotTurnIntent.ACKNOWLEDGED || + (next != null && (stopRun == null || next.startedAt != stopRun)) + current.copy( + stopIntent = if (clearStop) AidenBotTurnIntent.NONE else current.stopIntent, + resumeIntent = if (next != null) AidenBotTurnIntent.NONE else current.resumeIntent + ) + } + } + + private fun storeTranscript() { + val key = sessionKey ?: return + val current = _state.value + val session = current.session ?: return + if (current.isAccessBlocked) return + store?.storeTranscript(key, session.entries) + } + private suspend fun follow() { - refetch() + load(authoritative = false) while (scope.isActive && !_state.value.botMissing) { + var clean = true try { transport.events(botId).collect { event -> - if (!handle(event)) throw ReopenFeed() + streamUp() + if (!handle(event)) throw ReopenFeed(clean = event.payload is AidenBotSessionEventPayload.Closed) } } catch (error: CancellationException) { throw error - } catch (error: AidenRemoteClientException.Server) { + } catch (reopen: ReopenFeed) { + clean = reopen.clean + } catch (error: Exception) { // A deleted Bot has no feed to come back to. - if (error.statusCode == 404) { + if ((error as? AidenRemoteClientException.Server)?.statusCode == 404) { + downJob?.cancel() _state.update { it.copy(botMissing = true, isLoading = false, loadFailed = it.session == null) } + failWaiting() return } - // This phone isn't allowed to read the chat: retrying cannot help. - if (error.statusCode == 401 || error.statusCode == 403) { - _state.update { it.copy(isLoading = false, loadFailed = it.session == null) } + // This phone isn't allowed to read the chat: retrying cannot help, and nothing unsent is kept. + if (aidenBotLostAccess(error)) { + downJob?.cancel() + purgeForAccessLoss() + _state.update { it.copy(isLoading = false, loadFailed = it.session == null, accessLost = true) } return } - } catch (_: Exception) { - // Closed or dropped feed: wait, then reconnect; the next snapshot restores state. + clean = false } + // Closed or dropped: while a turn runs the line says "Reconnecting to your Mac". A + // clean close usually comes straight back, so it says so only if it doesn't. + if (clean) debounceStreamDown() else markStreamDown() delay(reconnectDelayMillis) } } - private class ReopenFeed : Exception("reopen Bot session feed") + private class ReopenFeed(val clean: Boolean) : Exception("reopen Bot session feed") + + private fun streamUp() { + downJob?.cancel() + downJob = null + if (_state.value.streamDown) _state.update { it.copy(streamDown = false) } + } + + private fun markStreamDown() { + downJob?.cancel() + downJob = null + _state.update { it.copy(streamDown = true) } + } + + private fun debounceStreamDown() { + if (downJob?.isActive == true || _state.value.streamDown) return + downJob = scope.launch { + delay(reconnectingDebounceMillis) + _state.update { it.copy(streamDown = true) } + } + } + + // --- Sending (R14, R16, R17) --- /** - * Sends [text]. Nothing is sent while the Bot needs an AI model or another send is in - * flight. A failed send keeps its key, so retrying the same text cannot post twice. + * Sends [text]. It shows at once as an outgoing message and goes out now when connected, + * or from the outbox on the next connection, under one Idempotency-Key. A Bot that needs an + * AI model takes nothing. While the Bot works on a Mac offering `bot-activity-v1`, + * [whenBusy] says Steer or Queue (Queue by default); otherwise it is left off, because an + * older Mac refuses it. + * + * Returns true when the message was taken (its bubble shows), false when refused. */ - private var pendingAnswer: Pair? = null - private var pendingApprovalDecision: Pair? = null - - suspend fun send(text: String): Boolean { + suspend fun send(text: String, whenBusy: AidenStreamInputMode? = null): Boolean { val trimmed = text.trim() + if (trimmed.isEmpty() || trimmed.codePointCount(0, trimmed.length) > AidenBotSessionWire.MAX_MESSAGE_LENGTH) return false + val current = _state.value + if (!current.canSend) return false + _state.update { it.copy(actionError = null, notice = null) } + val mode = if (current.offersBusyModes) whenBusy ?: AidenStreamInputMode.QUEUE else null + if (!enqueue(trimmed, mode, retryOf = null)) return false + ensureRestored() + flushNow() + return true + } + + /** + * Adds a message, unless the chat already holds as many (or as much) as it can keep for a + * later launch: then it says so, and nothing is stored that the next launch couldn't read. + */ + private fun enqueue(text: String, mode: AidenStreamInputMode?, retryOf: String?): Boolean { + val current = _state.value + val next = current.outbox + AidenBotOutgoingMessage.create(text, mode, current.isRunning, retryOf, nowMs()) + if (!fits(next)) { + _state.update { it.copy(notice = AidenBotSessionNotice.OUTBOX_FULL) } + return false + } + setOutbox(next) + return true + } + + /** Whether [messages] fit what this chat keeps for a later launch. */ + private fun fits(messages: List): Boolean = + messages.size <= AidenBotSessionStore.MAX_MESSAGES && (sessionKey == null || store == null || store.fits(sessionKey, messages)) + + /** + * Restored messages and those sent before the disk was read can together run past the + * limit. Nothing is dropped: the newest that never went out say "Couldn't send · Try + * again", so the person sends or removes them. Returns whether any did. + */ + private fun capped(messages: List): Pair, Boolean> { + var fit = messages.size + while (fit > 0 && !fits(messages.take(fit))) fit -= 1 + if (fit == messages.size) return messages to false + val capped = messages.mapIndexed { index, message -> + if (index >= fit && message.status == AidenBotOutgoingStatus.SENDING && !message.frozen) { + message.copy(status = AidenBotOutgoingStatus.FAILED, keepsKey = false) + } else { + message + } + } + return capped to (capped != messages) + } + + /** Try again on a message that couldn't be sent. One the Mac may hold keeps its key, so the Mac replays its receipt. */ + suspend fun retrySend(id: String): Boolean { + val message = _state.value.outbox.firstOrNull { it.id == id && it.status == AidenBotOutgoingStatus.FAILED } ?: return false + // An outbox past its limit (after a restore) takes no more until some are sent or removed. + if (!fits(_state.value.outbox)) { + _state.update { it.copy(notice = AidenBotSessionNotice.OUTBOX_FULL) } + return false + } + val base = if (message.keepsKey) message else message.copy(key = UUID.randomUUID(), frozen = false, sentMode = null, baseline = emptySet()) + val again = base.copy( + status = AidenBotOutgoingStatus.SENDING, + submissionId = null, + // Try again starts its day again. + createdAtMs = nowMs(), + connectionAttempts = 0, + deferred = false, + echoed = false, + echoEntryId = null, + echoSubmissionId = null, + queuedId = null, + unheld = false + ) + setOutbox(_state.value.outbox.map { if (it.id == id) again else it }) + flushNow() + return true + } + + /** Removes a message that couldn't be sent; nothing else is ever dropped silently. */ + fun discardSend(id: String) { + setOutbox(_state.value.outbox.filterNot { it.id == id && it.status == AidenBotOutgoingStatus.FAILED }) + } + + /** + * Sends the newest failed turn's text again through the outbox (R18): it shows at once and + * goes out with the other messages. A Retry is its own submission with its own key, never + * the original message's; a Retry that couldn't be sent is tried again under its key when + * the Mac may hold it. + */ + suspend fun retry(): Boolean { + val current = _state.value + if (!current.hasLoaded) return false + val failed = current.retryableFailedTurn ?: return false + val text = failed.retryText?.trim() ?: return false + current.outbox.firstOrNull { it.retryOf == failed.id }?.let { return retrySend(it.id) } + if (!enqueue(text, if (current.offersBusyModes) AidenStreamInputMode.QUEUE else null, retryOf = failed.id)) return false + flushNow() + return true + } + + /** + * Shows [next] and writes it through to the store, which holds it in memory until the disk + * copy was read, so a message sent meanwhile never overwrites what a previous launch left. A store + * that refuses the write (another controller adopted the chat, or the Mac was unpaired) ends + * this controller's sending, like closing the chat. + */ + private fun setOutbox(next: List, force: Boolean = false) { + if (!force && _state.value.outbox == next) return + _state.update { it.copy(outbox = next) } + val key = sessionKey ?: return + val store = store ?: return + if (!store.storeOutbox(key, this, next)) { + released = true + backoffJob?.cancel() + } + } + + /** Replaces the message with [key]; a null result removes it. */ + private fun updateMessage(key: UUID, change: (AidenBotOutgoingMessage) -> AidenBotOutgoingMessage?) { + setOutbox(_state.value.outbox.mapNotNull { if (it.key == key) change(it) else it }) + } + + /** + * Server truth replaces optimistic messages (R14, R16, R17). A taken message gives way to the + * entry with its `submissionId`; text stands in only where the entry has none (an older + * Mac), for a request still unsettled, or for a Queued bubble that beats the receipt. Each + * entry settles one message for the life of the epoch. An unsettled message is never + * removed: it hides and settles on its receipt. On a Mac that sends its queue, a taken + * message that is neither queued nor in the transcript is marked unheld by a snapshot and + * goes only once [unheldGraceMillis] have passed since (FR-m5, as on iOS): the Mac's Queued + * list may be full, or may leave it out while it can't yet tell whose it is. Showing up + * queued again clears the mark. + */ + private fun reconcileOutbox(snapshot: Boolean) { + val current = _state.value + val epoch = current.session?.epoch + if (claimsEpoch != epoch) { + claimsEpoch = epoch + claimedEntries.clear() + } + if (current.outbox.isEmpty()) return + val entries = current.session?.entries.orEmpty() + val owned = current.outbox.mapNotNull { it.submissionId }.toSet() + val queuedIds = current.queued.map { it.submissionId }.toSet() + val claimedQueued = owned.toMutableSet() + val kept = mutableListOf() + for (original in current.outbox) { + var message = original + if (!message.mayHaveReachedMac) { + kept += message + continue + } + if (!message.echoed) { + val echo = echoEntry(message, owned, entries) + if (echo != null) { + claimedEntries += echo.id + // Settled: the Mac's entry stands for it now. + if (message.status != AidenBotOutgoingStatus.SENDING) continue + message = message.copy(echoed = true, echoEntryId = echo.id, echoSubmissionId = echo.submissionId) + } + } + if (!message.echoed) { + val id = message.submissionId + if (id != null) { + if (id in queuedIds) { + message = message.copy(queuedId = id, unheld = false) + } else if (current.activitySupported) { + // Taken, not queued and not in the transcript: nothing on the Mac shows it. + // Marked on a snapshot; it goes once its 10 s grace ran out ([unheldTimers]), as on iOS. + when { + message.unheld && message.key in unheldExpired -> continue + snapshot && !message.unheld -> message = message.copy(unheld = true) + } + } + } else if (message.queuedId == null) { + // The Queued frame can beat the receipt. + val match = current.queued.firstOrNull { it.submissionId !in claimedQueued && it.text == message.text } + if (match != null) { + claimedQueued += match.submissionId + message = message.copy(queuedId = match.submissionId) + } + } + } + kept += message + } + setOutbox(kept) + trackUnheld() + } + + /** + * One grace timer per taken message that nothing on the Mac shows (FR-m5): when it runs + * out and the message is still unheld, the message goes. One the Mac shows again, or that + * settled, loses its timer. + */ + private fun trackUnheld() { + val unheld = _state.value.outbox.filter { it.unheld }.mapTo(HashSet()) { it.key } + (unheldTimers.keys - unheld).forEach { unheldTimers.remove(it)?.cancel() } + unheldExpired.retainAll(unheld) + for (key in unheld) { + if (key in unheldTimers || key in unheldExpired) continue + unheldTimers[key] = scope.launch { + delay(unheldGraceMillis) + unheldTimers.remove(key) + unheldExpired += key + reconcileOutbox(snapshot = false) + } + } + } + + /** The unclaimed user entry that stands for [message], if any. */ + private fun echoEntry( + message: AidenBotOutgoingMessage, + owned: Set, + entries: List + ): AidenBotSessionEntry.Message? { + var byText: AidenBotSessionEntry.Message? = null + for (entry in entries) { + val sent = entry as? AidenBotSessionEntry.Message ?: continue + if (sent.role != AidenBotMessageRole.USER || sent.id in claimedEntries) continue + if (message.submissionId != null && sent.submissionId == message.submissionId) return sent + if (byText != null || sent.id in message.baseline || sent.text.trim() != message.text) continue + // An entry that names its submission belongs to that submission. Only a request still + // unsettled may wait on one (its receipt says whose it is); a failed one never takes it. + val theirs = sent.submissionId + if (theirs != null && + (message.submissionId != null || theirs in owned || message.status == AidenBotOutgoingStatus.FAILED) + ) continue + byText = sent + } + return byText + } + + /** The Mac is reachable again: deferred messages get another try. */ + private fun wakeOutbox() { + setOutbox(_state.value.outbox.map { if (it.deferred) it.copy(deferred = false) else it }) + scheduleFlush() + } + + /** + * Sends the outbox in its own job beside whatever called this (a frame, a snapshot, a + * backoff), never holding up the feed; [stopFollowing] cancels it mid-request. + */ + private fun scheduleFlush() { + if (released || isFlushing || nextToDeliver() == null) return + flushJob = scope.launch { flushOutbox() } + } + + /** Sends now and waits until the outbox has gone as far as it can. */ + private suspend fun flushNow() { + scheduleFlush() + flushJob?.join() + } + + /** + * The oldest unsettled message, unless it waits for a retry: nothing overtakes it, so + * messages arrive in order. Nothing goes before a previous launch's messages are read. + */ + private fun nextToDeliver(): UUID? { + val current = _state.value + if (!current.isConnected || released || !restored) return null + val head = current.outbox.firstOrNull { it.status == AidenBotOutgoingStatus.SENDING } ?: return null + return if (head.deferred) null else head.key + } + + /** One sender at a time: a caller that finds one under way leaves the new message to it. */ + private suspend fun flushOutbox() { + if (isFlushing) return + isFlushing = true + try { + while (true) { + val key = nextToDeliver() ?: break + deliver(key) + } + } finally { + isFlushing = false + } + } + + private suspend fun deliver(key: UUID) { + val current = _state.value + val message = current.outbox.firstOrNull { it.key == key } ?: return + if (current.needsModel || current.state == AidenBotSessionState.UNAVAILABLE || current.botMissing) { + // A frozen request may have reached the Mac: Try again replays its key. Any entry it + // waited on goes back to the others. + message.echoEntryId?.let { claimedEntries -= it } + updateMessage(key) { + it.copy(status = AidenBotOutgoingStatus.FAILED, keepsKey = it.frozen, echoed = false, echoEntryId = null, echoSubmissionId = null) + } + return + } + if (message.isExpired(nowMs())) { + // Unsent for a day: it says so instead of going out late, and lets go of any entry it held. + message.echoEntryId?.let { claimedEntries -= it } + updateMessage(key) { it.expired().copy(echoEntryId = null, echoSubmissionId = null) } + return + } + val request = if (message.frozen) message else message.copy( + frozen = true, + sentMode = if (current.offersBusyModes) message.mode else null, + baseline = current.session?.entries?.map { it.id }?.toSet().orEmpty() + ) + if (request != message) updateMessage(key) { request } + // The store refused the frozen request (another controller took the chat, or an unpair). + if (released) return + val writes = stateWrites + _state.update { it.copy(isSending = true) } + try { + val receipt = bounded { transport.send(botId, request.text, key, request.sentMode) } + if (stateWrites == writes) applyStateView(receipt.stateView) + updateMessage(key) { latest -> + var settled = latest + if (settled.echoed && settled.echoSubmissionId != null && settled.echoSubmissionId != receipt.submissionId) { + // The entry it waited on was another submission's: give it back. + settled.echoEntryId?.let { claimedEntries -= it } + settled = settled.copy(echoed = false, echoEntryId = null, echoSubmissionId = null) + } + if (settled.echoed) { + null + } else { + settled.copy( + status = AidenBotOutgoingStatus.SENT, + submissionId = receipt.submissionId, + deferred = false, + keepsKey = false + ) + } + } + reconcileOutbox(snapshot = false) + } catch (error: CancellationException) { + // A closed chat leaves the message as it was, key and body, for the next visit. + throw error + } catch (error: Exception) { + if (released) return + val latest = _state.value.outbox.firstOrNull { it.key == key } ?: return + if (aidenBotLostAccess(error)) { + purgeForAccessLoss() + return + } + if (!aidenBotMessageFailureIsAmbiguous(error)) { + // Refused outright: it never reached the Mac, and Try again is a new request. + updateMessage(key) { it.copy(status = AidenBotOutgoingStatus.FAILED, keepsKey = false, echoed = false) } + return + } + val attempts = latest.connectionAttempts + 1 + if (attempts < MAX_CONNECTION_ATTEMPTS) { + // It keeps its place and key: the next connection, or a short wait while + // connected, tries again. Nothing behind it overtakes it. + updateMessage(key) { it.copy(keepsKey = true, connectionAttempts = attempts, deferred = true) } + scheduleBackoff(attempts) + } else { + updateMessage(key) { + if (it.echoed && it.echoSubmissionId == null) { + // An untagged entry of its text already shows it. + null + } else { + // An entry naming another submission is not its echo: "Couldn't send · Try + // again" with its key, and the entry goes back to whoever it belongs to. + it.echoEntryId?.let { entry -> claimedEntries -= entry } + it.copy( + status = AidenBotOutgoingStatus.FAILED, + keepsKey = true, + connectionAttempts = attempts, + echoed = false, + echoEntryId = null, + echoSubmissionId = null + ) + } + } + } + } finally { + _state.update { it.copy(isSending = false) } + } + } + + private fun scheduleBackoff(attempts: Int) { + if (!_state.value.isConnected || retryDelaysMillis.isEmpty()) return + val wait = retryDelaysMillis[minOf(attempts - 1, retryDelaysMillis.lastIndex)] + backoffJob?.cancel() + backoffJob = scope.launch { + delay(wait) + if (!released) wakeOutbox() + } + } + + /** Access ended: nothing unsent is kept for this chat, in memory or on disk, and the person is told. */ + private fun purgeForAccessLoss() { + val hadUnsent = _state.value.outbox.any { it.status != AidenBotOutgoingStatus.SENT } + // A disk restore still under way is stale from here on, and the chat has nothing on disk left to wait for. + purgeGeneration += 1 + restoreJob?.cancel() + restored = true + backoffJob?.cancel() + setOutbox(emptyList()) + sessionKey?.let { store?.purge(it) } + if (hadUnsent) _state.update { it.copy(notice = AidenBotSessionNotice.ACCESS_LOST) } + } + + /** The Bot is gone: what waited to go to it says it couldn't be sent. */ + private fun failWaiting() { + setOutbox(_state.value.outbox.map { + if (it.status == AidenBotOutgoingStatus.SENDING) it.copy(status = AidenBotOutgoingStatus.FAILED, echoed = false) else it + }) + } + + // --- Turn controls (R14, R18): each applies at once and goes back only if nothing newer replaced it --- + + /** Resume shows "Picking up where it left off" at once and goes back if the Mac didn't take it. A second tap sends nothing. */ + suspend fun resume(): Boolean { val current = _state.value - if (trimmed.isEmpty() || !current.canSend) return false - if (pendingSendText != trimmed) { - keys.complete(SEND) - pendingSendText = trimmed + val before = current.session ?: return false + if (!current.isInterrupted || current.isAccessBlocked || RESUME in inFlight) return false + inFlight += RESUME + val writes = stateWrites + _state.update { + it.copy( + session = it.session?.copy(state = AidenBotSessionState.RUNNING, interrupted = false, blocked = null, activity = null), + resumeIntent = AidenBotTurnIntent.REQUESTED, + isResuming = true, + actionError = null + ) } - val key = keys.key(SEND) - _state.update { it.copy(isSending = true, actionError = null) } return try { - val receipt = transport.send(botId, trimmed, key) - keys.complete(SEND) - pendingSendText = null - applyStateView(receipt.stateView) + val view = bounded { transport.resume(botId, keys.key(RESUME)) } + keys.complete(RESUME) + if (stateWrites == writes) applyStateView(view) + _state.update { + if (it.resumeIntent == AidenBotTurnIntent.REQUESTED) it.copy(resumeIntent = AidenBotTurnIntent.ACKNOWLEDGED) else it + } true } catch (error: CancellationException) { throw error } catch (error: Exception) { - keys.failed(SEND, error) - if (aidenBotMacAnswered(error)) pendingSendText = null - _state.update { it.copy(actionError = "Aiden couldn’t send that. Try again.") } + keys.failed(RESUME, error) + _state.update { + it.copy( + session = if (stateWrites == writes) it.session?.restoringState(before) else it.session, + resumeIntent = AidenBotTurnIntent.NONE, + actionError = UNREACHABLE + ) + } false } finally { - _state.update { it.copy(isSending = false) } + inFlight -= RESUME + _state.update { it.copy(isResuming = false) } + } + } + + /** Dismiss clears the paused card at once and brings it back if the Mac didn't take it. */ + suspend fun dismiss(): Boolean { + val current = _state.value + val before = current.session ?: return false + if (!current.isInterrupted || DISMISS in inFlight) return false + inFlight += DISMISS + val writes = stateWrites + _state.update { + it.copy( + session = it.session?.copy(state = AidenBotSessionState.IDLE, interrupted = false, blocked = null, activity = null), + isDismissing = true, + actionError = null + ) + } + return try { + val view = bounded { transport.dismiss(botId, keys.key(DISMISS)) } + keys.complete(DISMISS) + if (stateWrites == writes) applyStateView(view) + true + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + keys.failed(DISMISS, error) + _state.update { + it.copy( + session = if (stateWrites == writes) it.session?.restoringState(before) else it.session, + actionError = UNREACHABLE + ) + } + false + } finally { + inFlight -= DISMISS + _state.update { it.copy(isDismissing = false) } } } /** - * Resends the newest failed turn's text as a new message. The retry has its own key, never - * the original message's: an unanswered retry keeps it, so a second tap on the same failed - * turn replays instead of posting twice, and another failed turn gets a fresh one. + * Stop says "Stopping" at once (R14) and goes back if the Mac didn't take it. Once the Mac + * took it, "Stopping" holds until its next activity frame or the turn ends. Pressed with + * nothing running (a card waiting), it says nothing, so the next turn never starts at "Stopping". */ - suspend fun retry(): Boolean { - val failed = _state.value.retryableFailedTurn ?: return false - val text = failed.retryText ?: return false - val action = "$RETRY:${failed.id}" - var claimed = false - _state.update { current -> - if (current.isSending) current else { - claimed = true - current.copy(isSending = true, actionError = null) - } + suspend fun stop(): Boolean { + val current = _state.value + if (!current.canStopTurn || STOP in inFlight) return false + inFlight += STOP + if (current.isRunning) { + stopStartedAt = current.session?.activity?.startedAt + _state.update { it.copy(stopIntent = AidenBotTurnIntent.REQUESTED, actionError = null) } + } else { + _state.update { it.copy(actionError = null) } } - if (!claimed) return false + val writes = stateWrites return try { - val receipt = transport.send(botId, text, keys.key(action)) - keys.complete(action) - applyStateView(receipt.stateView) + val view = bounded { transport.stop(botId, keys.key(STOP)) } + keys.complete(STOP) + if (stateWrites == writes) applyStateView(view) + _state.update { + if (it.stopIntent == AidenBotTurnIntent.REQUESTED) it.copy(stopIntent = AidenBotTurnIntent.ACKNOWLEDGED) else it + } true } catch (error: CancellationException) { throw error } catch (error: Exception) { - keys.failed(action, error) - _state.update { it.copy(actionError = "Aiden couldn’t send that. Try again.") } + keys.failed(STOP, error) + _state.update { it.copy(stopIntent = AidenBotTurnIntent.NONE, actionError = UNREACHABLE) } false } finally { - _state.update { it.copy(isSending = false) } + inFlight -= STOP } } - /** Resume a paused turn. A second tap while one is in flight sends nothing. */ - suspend fun resume() = control( - RESUME, - inFlight = { it.isResuming }, - mark = { s, v -> s.copy(isResuming = v) }, - call = { key -> transport.resume(botId, key) } - ) - - suspend fun dismiss() = control( - DISMISS, - inFlight = { it.isDismissing }, - mark = { s, v -> s.copy(isDismissing = v) }, - call = { key -> transport.dismiss(botId, key) } - ) - - suspend fun stop() = control( - STOP, - inFlight = { it.isStopping }, - mark = { s, v -> s.copy(isStopping = v) }, - call = { key -> transport.stop(botId, key) } - ) - /** - * Answers the waiting A–E question once. A failed answer keeps its key, so retrying the - * same answer replays the Mac's receipt instead of answering twice; a different answer - * gets a new key. A second tap while one is in flight sends nothing. + * Answers the waiting A–E question once. The card goes at once and comes back with an error + * if the answer didn't reach the Mac, unless the Mac has since shown another (R18). A failed + * answer keeps its key, so retrying the same answer replays the Mac's receipt; a different + * answer gets a new key. A second tap while one is in flight sends nothing. */ suspend fun answerQuestion(response: AidenQuestionRespondRequest): Boolean { val question = _state.value.session?.question ?: return false @@ -440,24 +1292,27 @@ class AidenBotSessionController( keys.complete(action) pendingAnswer = action to response } - _state.update { it.copy(isAnsweringQuestion = true, actionError = null) } + val writes = questionWrites + _state.update { + it.copy(isAnsweringQuestion = true, actionError = null, session = it.session?.copy(question = null)) + } return try { - transport.answerQuestion(botId, question.waitId, response, keys.key(action)) + bounded { transport.answerQuestion(botId, question.waitId, response, keys.key(action)) } keys.complete(action) pendingAnswer = null - _state.update { current -> - val session = current.session - if (session?.question?.waitId == question.waitId) { - current.copy(session = session.copy(question = null)) - } else { - current - } - } true } catch (error: CancellationException) { throw error - } catch (_: Exception) { - _state.update { it.copy(actionError = "Aiden couldn’t send that answer. Try again.") } + } catch (error: Exception) { + keys.failed(action, error) + if (aidenBotMacAnswered(error)) pendingAnswer = null + _state.update { current -> + val session = current.session + current.copy( + session = if (questionWrites == writes && session?.question == null) session?.copy(question = question) else session, + actionError = ANSWER_FAILED + ) + } false } finally { _state.update { it.copy(isAnsweringQuestion = false) } @@ -465,19 +1320,21 @@ class AidenBotSessionController( } /** - * Allows or denies the waiting tool approval once. Allow is refused when the approval - * cannot be allowed from a phone. An unanswered attempt keeps its key, so retrying the - * same decision replays the Mac's outcome; the other decision gets a new key. An expired - * or already-settled approval is cleared. A second tap while one is in flight sends nothing. + * Allows or denies the waiting tool approval once. The card goes at once and comes back with + * an error if the decision didn't reach the Mac, unless the Mac has since changed it (R18). + * Allow is refused when the approval cannot be allowed from a phone. An unanswered attempt + * keeps its key, so retrying the same decision replays the Mac's outcome; the other decision + * gets a new key. An expired or already-settled approval stays gone. */ suspend fun respondToApproval(decision: AidenApprovalDecision): Boolean { val approval = _state.value.session?.approval ?: return false if (decision == AidenApprovalDecision.ALLOW && !approval.canAllow) return false var claimed = false + val writes = approvalWrites _state.update { current -> if (current.isRespondingToApproval) current else { claimed = true - current.copy(isRespondingToApproval = true, actionError = null) + current.copy(isRespondingToApproval = true, actionError = null, session = current.session?.copy(approval = null)) } } if (!claimed) return false @@ -487,10 +1344,9 @@ class AidenBotSessionController( pendingApprovalDecision = action to decision } return try { - transport.respondToApproval(approval.waitId, decision, keys.key(action)) + bounded { transport.respondToApproval(approval.waitId, decision, keys.key(action)) } keys.complete(action) pendingApprovalDecision = null - clearApproval(approval.waitId) true } catch (error: CancellationException) { throw error @@ -498,13 +1354,16 @@ class AidenBotSessionController( keys.failed(action, error) if (aidenBotMacAnswered(error)) pendingApprovalDecision = null when ((error as? AidenRemoteClientException.Server)?.body?.code) { - AidenRemoteErrorCode.APPROVAL_EXPIRED -> { - clearApproval(approval.waitId) - _state.update { it.copy(actionError = "That request expired.") } - } + AidenRemoteErrorCode.APPROVAL_EXPIRED -> _state.update { it.copy(actionError = "That request expired.") } // Settled elsewhere, on the Mac or another phone: nothing is waiting any more. - AidenRemoteErrorCode.APPROVAL_ALREADY_RESOLVED -> clearApproval(approval.waitId) - else -> _state.update { it.copy(actionError = "Aiden couldn’t send that. Try again.") } + AidenRemoteErrorCode.APPROVAL_ALREADY_RESOLVED -> Unit + else -> _state.update { current -> + val session = current.session + current.copy( + session = if (approvalWrites == writes && session?.approval == null) session?.copy(approval = approval) else session, + actionError = SEND_FAILED + ) + } } false } finally { @@ -512,20 +1371,13 @@ class AidenBotSessionController( } } - private fun clearApproval(waitId: String) { - _state.update { current -> - val session = current.session - if (session?.approval?.waitId == waitId) current.copy(session = session.copy(approval = null)) else current - } - } - suspend fun requestConnection(pluginId: String) { val phase = _state.value.connectRequests[pluginId] if (phase == AidenBotConnectRequestPhase.SENDING || phase == AidenBotConnectRequestPhase.SENT) return val action = "connect:$pluginId" _state.update { it.copy(connectRequests = it.connectRequests + (pluginId to AidenBotConnectRequestPhase.SENDING)) } val next = try { - transport.requestConnection(botId, pluginId, keys.key(action)) + bounded { transport.requestConnection(botId, pluginId, keys.key(action)) } keys.complete(action) AidenBotConnectRequestPhase.SENT } catch (error: CancellationException) { @@ -538,10 +1390,10 @@ class AidenBotSessionController( } /** - * Answers a pending routine proposal once. The card settles straight from the Mac's answer - * ("Added ✓" or "Not added"); a failure keeps Add routine and Not now so the person can try - * again, with the same key for the same choice. A proposal the Mac no longer has reloads the - * session. A second tap while one is in flight sends nothing. + * Answers a pending routine proposal once. The card settles at once ("Added ✓" or "Not + * added") and the Mac's answer replaces it; a failure puts Add routine and Not now back so + * the person can try again, with the same key for the same choice (R18). A proposal the Mac + * no longer has reloads the session. A second tap while one is in flight sends nothing. */ suspend fun respondToProposal(proposalId: String, decision: AidenBotRoutineProposalDecision): Boolean { val card = _state.value.session?.entries @@ -557,9 +1409,18 @@ class AidenBotSessionController( } } if (!claimed) return false + val optimistic = card.copy( + status = if (decision == AidenBotRoutineProposalDecision.ACCEPT) { + AidenBotRoutineProposalStatus.ACCEPTED + } else { + AidenBotRoutineProposalStatus.DISMISSED + }, + routineId = null + ) + replaceProposal(proposalId) { if (it == card) optimistic else it } val action = "$RESPOND_PROPOSAL:$proposalId:${decision.name}" return try { - val result = transport.respondToRoutineProposal(botId, proposalId, decision, keys.key(action)) + val result = bounded { transport.respondToRoutineProposal(botId, proposalId, decision, keys.key(action)) } keys.complete(action) settleProposal(proposalId, result) true @@ -567,6 +1428,7 @@ class AidenBotSessionController( throw error } catch (error: Exception) { keys.failed(action, error) + replaceProposal(proposalId) { if (it == optimistic) card else it } _state.update { it.copy(proposalResponses = it.proposalResponses + (proposalId to AidenBotProposalPhase.FAILED)) } val server = error as? AidenRemoteClientException.Server if (server?.statusCode == 404 && server.body.code == AidenRemoteErrorCode.ROUTINE_PROPOSAL_NOT_FOUND) { @@ -577,73 +1439,56 @@ class AidenBotSessionController( } } - private fun settleProposal(proposalId: String, result: AidenBotRoutineProposalRespondResult) { - val status = when (result.status) { - AidenBotRoutineProposalOutcome.ACCEPTED -> AidenBotRoutineProposalStatus.ACCEPTED - AidenBotRoutineProposalOutcome.DISMISSED -> AidenBotRoutineProposalStatus.DISMISSED - } + private fun replaceProposal( + proposalId: String, + change: (AidenBotSessionEntry.RoutineProposal) -> AidenBotSessionEntry.RoutineProposal + ) { _state.update { current -> - val session = current.session - val entries = session?.entries?.map { entry -> - if (entry is AidenBotSessionEntry.RoutineProposal && entry.proposalId == proposalId) { - entry.copy(status = status, routineId = result.routineId) - } else { - entry - } - } - current.copy( - session = if (session != null && entries != null) session.copy(entries = entries) else session, - proposalResponses = current.proposalResponses - proposalId - ) + val session = current.session ?: return@update current + current.copy(session = session.copy(entries = session.entries.map { entry -> + if (entry is AidenBotSessionEntry.RoutineProposal && entry.proposalId == proposalId) change(entry) else entry + })) } } - private suspend fun control( - action: String, - inFlight: (AidenBotSessionUiState) -> Boolean, - mark: (AidenBotSessionUiState, Boolean) -> AidenBotSessionUiState, - call: suspend (UUID) -> AidenBotSessionStateView - ): Boolean { - var claimed = false - _state.update { current -> - if (inFlight(current)) current else { - claimed = true - mark(current, true).copy(actionError = null) - } - } - if (!claimed) return false - return try { - val view = call(keys.key(action)) - keys.complete(action) - applyStateView(view) - true - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - keys.failed(action, error) - _state.update { it.copy(actionError = "Aiden couldn’t reach your Mac. Try again.") } - false - } finally { - _state.update { mark(it, false) } + private fun settleProposal(proposalId: String, result: AidenBotRoutineProposalRespondResult) { + val status = when (result.status) { + AidenBotRoutineProposalOutcome.ACCEPTED -> AidenBotRoutineProposalStatus.ACCEPTED + AidenBotRoutineProposalOutcome.DISMISSED -> AidenBotRoutineProposalStatus.DISMISSED } + replaceProposal(proposalId) { it.copy(status = status, routineId = result.routineId) } + _state.update { it.copy(proposalResponses = it.proposalResponses - proposalId) } } + /** The Mac's own answer to an action: its state replaces the local one. */ private fun applyStateView(view: AidenBotSessionStateView) { + val previous = _state.value.session _state.update { current -> val session = current.session ?: return@update current.copy(knownState = view.state) current.copy(session = session.copy(state = view.state, interrupted = view.interrupted, blocked = view.blocked)) } + afterStateWrite(previous) } + private fun AidenBotSession.restoringState(before: AidenBotSession) = + copy(state = before.state, interrupted = before.interrupted, blocked = before.blocked, activity = before.activity) + companion object { - const val SEND = "send" const val RESUME = "resume" const val DISMISS = "dismiss" const val STOP = "stop" const val ANSWER_QUESTION = "answerQuestion" const val RESPOND_APPROVAL = "respondApproval" - const val RETRY = "retry" const val RESPOND_PROPOSAL = "respondProposal" + /** Tries a message gets on a connection that keeps failing before it says "Couldn't send". */ + const val MAX_CONNECTION_ATTEMPTS = 3 + const val SEND_FAILED = "Aiden couldn’t send that. Try again." + const val UNREACHABLE = "Aiden couldn’t reach your Mac. Try again." + const val ANSWER_FAILED = "Aiden couldn’t send that answer. Try again." + /** The bound on each request to the Mac (FR-I3), the same as iOS. */ + const val REQUEST_LIMIT_MILLIS = 12_000L + /** How long a taken message nothing on the Mac shows waits for a second look (FR-m5). */ + const val UNHELD_GRACE_MILLIS = 10_000L } } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt index de755ec4b..2aaa1bbbd 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt @@ -40,9 +40,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import sbtbiswas.AidenOnTheGo.features.chat.AidenPowerSaveScope import sbtbiswas.AidenOnTheGo.R import sbtbiswas.AidenOnTheGo.features.chat.AidenQuestionCard +import sbtbiswas.AidenOnTheGo.features.chat.AidenRunInputSplitButton import sbtbiswas.AidenOnTheGo.features.remote.AidenRemoteCoordinator import sbtbiswas.AidenOnTheGo.models.* import sbtbiswas.AidenOnTheGo.ui.theme.AidenPrimaryButton @@ -118,6 +122,22 @@ fun AidenBotSessionScreen( onBotDeleted: () -> Unit = onNavigateBack, /** The "Memory updated" caption opens Profile → Memory. */ onNavigateToBotMemory: (String) -> Unit = onNavigateToBotProfile +) { + // One battery-saver receiver for the chat's faces, marks and skeletons. + AidenPowerSaveScope { + AidenBotSessionContent(botId, coordinator, onNavigateBack, onNavigateToBotProfile, botDeleter, onBotDeleted, onNavigateToBotMemory) + } +} + +@Composable +private fun AidenBotSessionContent( + botId: String, + coordinator: AidenRemoteCoordinator, + onNavigateBack: () -> Unit, + onNavigateToBotProfile: (String) -> Unit, + botDeleter: AidenBotDeleter?, + onBotDeleted: () -> Unit, + onNavigateToBotMemory: (String) -> Unit ) { val palette = AidenTheme.palette val resources = LocalResources.current @@ -128,6 +148,7 @@ fun AidenBotSessionScreen( val knownState = coordinator.botCache.botList.value?.bots?.firstOrNull { it.id == botId }?.sessionState ?: coordinator.botCache.getBotDetail(botId)?.sessionState val cl = client + val activitySupportedNow = aidenBotActivitySupported(serverInfo, coordinator.cachedBotActivitySupported) if (cl == null) { Box(Modifier.fillMaxSize().background(palette.canvas), contentAlignment = Alignment.Center) { Text(stringResource(R.string.bot_session_connect), color = palette.secondary) @@ -135,25 +156,72 @@ fun AidenBotSessionScreen( return } val controller = remember(botId, cl) { - AidenBotSessionController(botId, AidenRemoteBotSessionTransport(cl), scope, knownState) + // The pairing names the chat's cached transcript and its unsent messages (R14, R17). + val sessionKey = coordinator.installationStore.activeInstallation?.let { AidenBotSessionKey(it.instanceId, it.deviceId, botId) } + AidenBotSessionController( + botId, + AidenRemoteBotSessionTransport(cl), + scope, + knownState, + activitySupported = activitySupportedNow, + sessionKey = sessionKey, + store = coordinator.botSessionStore + ) } DisposableEffect(controller) { controller.start() onDispose { controller.stopFollowing() } } val ui by controller.state.collectAsStateWithLifecycle() + val activitySupported = activitySupportedNow + LaunchedEffect(controller, activitySupported) { controller.setActivitySupported(activitySupported) } + // The wording comes from the Bot's detail: cached first, then the Mac's. Only a Mac that + // offers Chat activity now decides it, and the feed never waits for this read. Only this + // Bot's detail is followed, so another Bot's save doesn't redraw the chat. + val detailFlow = remember(botId) { coordinator.botCache.botDetails.map { it[botId] }.distinctUntilChanged() } + val botDetail by detailFlow.collectAsStateWithLifecycle(coordinator.botCache.getBotDetail(botId)) + val chatActivity = aidenBotChatActivity(serverInfo, botDetail, coordinator.cachedBotActivitySupported) + LaunchedEffect(controller, chatActivity) { + controller.setWording(chatActivity?.wording ?: AidenBotChatActivityWording.FOLLOWS) + } + // One read of the Bot per visit; Advanced opens from it (or the cache) without another. + LaunchedEffect(controller) { + val pairing = coordinator.activePairing + try { + val fresh = cl.bot(botId) + // A read that outlived its pairing never reaches another pairing's cache. + if (coordinator.holdsReadAuthority(cl, pairing?.instanceId) && coordinator.holdsPairing(pairing)) { + coordinator.botCache.putBotDetail(fresh) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Keep the cached or default wording; the line still works. + } + } var draft by rememberSaveable(botId) { mutableStateOf("") } + // Steer or Queue while the Bot works: Queue by default, and Queue again once the turn ends. + var busyMode by remember(botId) { mutableStateOf(AidenStreamInputMode.QUEUE) } + LaunchedEffect(ui.isRunning) { busyMode = aidenBotBusyMode(busyMode, ui.isRunning) } var confirmingDelete by remember { mutableStateOf(false) } var isDeleting by remember { mutableStateOf(false) } // "Set up" (no AI model) and "Advanced" (access changed) open the Bot's Advanced settings. var advancedBot by remember { mutableStateOf(null) } var openingAdvanced by remember { mutableStateOf(false) } + // Advanced opens at once from the Bot this visit read (or the cache), never waiting on the + // connection (R15). Only with nothing cached does it read the Bot first. fun openAdvanced() { + botDetail?.let { + advancedBot = it + return + } if (openingAdvanced) return openingAdvanced = true + val pairing = coordinator.activePairing scope.launch { try { val detail = cl.bot(botId) + if (!coordinator.holdsReadAuthority(cl, pairing?.instanceId) || !coordinator.holdsPairing(pairing)) return@launch coordinator.botCache.putBotDetail(detail) advancedBot = detail } catch (e: CancellationException) { @@ -193,13 +261,24 @@ fun AidenBotSessionScreen( val canAnswerProposals = serverInfo?.supportsBotProactive == true val canOpenMemory = serverInfo?.supportsBotMemory == true val listState = rememberLazyListState() - val entries = ui.session?.entries.orEmpty() + // The Mac's transcript, or the cached one while connecting (R14). + val entries = ui.entries val rows = remember(entries) { aidenBotSessionRows(entries) } val partial = ui.session?.partial + val outgoing = ui.outgoing + // Set by this person's own send, so their message scrolls into view even when they had scrolled up. + var justSent by remember { mutableStateOf(false) } - LaunchedEffect(entries.size, partial, ui.isInterrupted, ui.needsModel) { - val count = listState.layoutInfo.totalItemsCount - if (count > 0) listState.animateScrollToItem(count - 1) + LaunchedEffect( + entries.size, partial, ui.isInterrupted, ui.needsModel, + ui.queued.size, outgoing.size, ui.session?.activity?.phase + ) { + val layout = listState.layoutInfo + val count = layout.totalItemsCount + val last = layout.visibleItemsInfo.lastOrNull()?.index ?: -1 + // Reading back stays put; only the end of the chat (or one's own send) follows new rows. + if (count > 0 && aidenBotFollowsTranscriptEnd(last, count, justSent)) listState.animateScrollToItem(count - 1) + justSent = false } if (confirmingDelete) { @@ -231,14 +310,27 @@ fun AidenBotSessionScreen( } advancedBot?.let { bot -> + val chatActivityHost = remember(cl) { coordinator.botChatActivityHost() } + val saveHost = remember(cl) { coordinator.botSaveHost() } AidenBotAdvancedSheet( bot = bot, client = cl, onDismiss = { advancedBot = null }, - onSaved = { saved -> - coordinator.botCache.putBotDetail(saved) + // Save applies at once (R15) and writes through the pinned cache, so a save that lands + // after an unpair or re-pair never reaches the other pairing's cache. + onSaved = { advancedBot = null scope.launch { controller.refetch() } + }, + saveHost = saveHost, + onSaveSettled = { scope.launch { controller.refetch() } }, + onSaveFailed = coordinator::presentError, + chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true, cachedSupport = coordinator.cachedBotActivitySupported), + chatActivityHost = chatActivityHost, + // The host has already cached the pick (pinned to its pairing); this only refreshes the chat. + onChatActivitySaved = { saved -> + controller.setWording(saved.chatActivity?.wording ?: AidenBotChatActivityWording.FOLLOWS) + scope.launch { controller.refetch() } } ) } @@ -274,13 +366,15 @@ fun AidenBotSessionScreen( contentPadding = PaddingValues(horizontal = AidenUi.ScreenGutter, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { - if (ui.isLoading && ui.session == null) { - item(key = "loading") { AidenBotSessionSkeleton() } - } else if (ui.loadFailed && ui.session == null && !ui.needsModel) { + // Skeletons while the first read is on its way; they hold still once it fails, + // and the hint above the composer offers Try again. + val skeleton = aidenBotSkeletonLook(ui) + if (skeleton != AidenBotSkeletonLook.HIDDEN) { + item(key = "loading") { AidenBotSessionSkeleton(shimmer = skeleton == AidenBotSkeletonLook.SHIMMERING) } + } else if (ui.botMissing && ui.session == null) { item(key = "failed") { Text( - if (ui.botMissing) stringResource(R.string.bot_session_missing) - else stringResource(R.string.bot_session_load_failed), + stringResource(R.string.bot_session_missing), color = palette.secondary, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 48.dp) @@ -328,6 +422,18 @@ fun AidenBotSessionScreen( AidenBotAssistantBubble(text = partial, streaming = ui.isRunning) } } + // The Mac's Queued bubbles, then this phone's own messages on their way (R14). + items(ui.queued, key = { "queued-${it.submissionId}" }) { message -> AidenBotQueuedBubble(message) } + items(outgoing, key = { it.id }) { message -> + AidenBotOutgoingBubble( + message = message, + look = ui.look(message), + canRetry = ui.canSend, + onRetry = { scope.launch { controller.retrySend(message.id) } }, + onRemove = { controller.discardSend(message.id) } + ) + } + item(key = "status") { AidenBotStatusLine(ui) } if (ui.isInterrupted) { item(key = "interrupted") { AidenBotInterruptedCard( @@ -364,21 +470,30 @@ fun AidenBotSessionScreen( if (ui.needsModel) { item(key = "needs-model") { AidenBotNeedsModelCard(busy = openingAdvanced, onSetUp = ::openAdvanced) } } - ui.actionError?.let { message -> + // A notice about unsent messages (copy shared with iOS), else the last action's error. + (ui.notice?.let { resources.getString(it.text) } ?: ui.actionError)?.let { message -> item(key = "error") { - Text(message, color = palette.danger, style = MaterialTheme.typography.bodySmall) + Text(message, color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall) } } } + AidenBotConnectionHintRow(aidenBotConnectionHint(ui, System.currentTimeMillis()), onTryAgain = { scope.launch { controller.refetch() } }) AidenBotSessionComposer( draft = draft, onDraftChange = { draft = it.take(AidenBotSessionWire.MAX_MESSAGE_LENGTH) }, placeholder = aidenBotChatPlaceholder(identity.name), enabled = ui.canSend, + busy = ui.offersBusyModes, + busyMode = busyMode, + onPickBusyMode = { busyMode = it }, onSend = { + // The draft clears at once and the bubble shows (R14); a refused message comes back. val text = draft + val mode = if (ui.offersBusyModes) busyMode else null + draft = "" + justSent = true scope.launch { - if (controller.send(text)) draft = "" + if (!controller.send(text, mode) && draft.isEmpty()) draft = text } } ) @@ -387,11 +502,12 @@ fun AidenBotSessionScreen( } /** The person's bubble: rounded on every corner but the one nearest the composer. */ -private val AidenBotUserBubbleShape = RoundedCornerShape(20.dp, 20.dp, 4.dp, 20.dp) +internal val AidenBotUserBubbleShape = RoundedCornerShape(20.dp, 20.dp, 4.dp, 20.dp) -/** Transcript-shaped placeholders while a Bot chat's first read is on its way. */ +/** Transcript-shaped placeholders while a Bot chat's first read is on its way; still once it fails. */ @Composable -private fun AidenBotSessionSkeleton() { +private fun AidenBotSessionSkeleton(shimmer: Boolean) { + val animate = shimmer && aidenBotIsResumed() val loadingDescription = stringResource(R.string.bot_session_loading) Column( verticalArrangement = Arrangement.spacedBy(14.dp), @@ -400,11 +516,11 @@ private fun AidenBotSessionSkeleton() { .padding(top = 12.dp) .clearAndSetSemantics { contentDescription = loadingDescription } ) { - AidenSkeletonBlock(width = 220.dp, height = 44.dp, shape = MaterialTheme.shapes.large, modifier = Modifier.align(Alignment.End)) - AidenSkeletonBlock(height = 14.dp) - AidenSkeletonBlock(width = 240.dp, height = 14.dp) - AidenSkeletonBlock(width = 180.dp, height = 44.dp, shape = MaterialTheme.shapes.large, modifier = Modifier.align(Alignment.End)) - AidenSkeletonBlock(height = 14.dp) + AidenSkeletonBlock(width = 220.dp, height = 44.dp, shape = MaterialTheme.shapes.large, animate = animate, modifier = Modifier.align(Alignment.End)) + AidenSkeletonBlock(height = 14.dp, animate = animate) + AidenSkeletonBlock(width = 240.dp, height = 14.dp, animate = animate) + AidenSkeletonBlock(width = 180.dp, height = 44.dp, shape = MaterialTheme.shapes.large, animate = animate, modifier = Modifier.align(Alignment.End)) + AidenSkeletonBlock(height = 14.dp, animate = animate) } } @@ -416,11 +532,12 @@ private fun AidenBotSessionMessageRow(message: AidenBotSessionEntry.Message) { message.label?.let { label -> Text(label, style = MaterialTheme.typography.labelSmall, color = palette.secondary, modifier = Modifier.padding(bottom = 4.dp, end = 4.dp)) } - Surface(color = palette.accent, shape = AidenBotUserBubbleShape, modifier = Modifier.widthIn(max = 320.dp)) { + val colors = aidenBotUserBubbleColors(MaterialTheme.colorScheme, faded = false) + Surface(color = colors.container, shape = AidenBotUserBubbleShape, modifier = Modifier.widthIn(max = 320.dp)) { Text( message.text, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onPrimary, + color = colors.content, modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp) ) } @@ -551,7 +668,7 @@ fun AidenBotConnectCard( onClick = onRequest ) if (phase == AidenBotConnectRequestPhase.FAILED) { - Text(stringResource(R.string.bot_session_unreachable), style = MaterialTheme.typography.bodySmall, color = palette.danger) + Text(stringResource(R.string.bot_session_unreachable), style = MaterialTheme.typography.bodySmall, color = aidenBotErrorTextColor()) } } } @@ -664,7 +781,7 @@ fun AidenBotRoutineProposalCard( ) } if (phase == AidenBotProposalPhase.FAILED) { - Text(stringResource(R.string.bot_proposal_failed), style = MaterialTheme.typography.bodySmall, color = palette.danger) + Text(stringResource(R.string.bot_proposal_failed), style = MaterialTheme.typography.bodySmall, color = aidenBotErrorTextColor()) } } } @@ -678,6 +795,9 @@ private fun AidenBotSessionComposer( onDraftChange: (String) -> Unit, placeholder: String, enabled: Boolean, + busy: Boolean, + busyMode: AidenStreamInputMode, + onPickBusyMode: (AidenStreamInputMode) -> Unit, onSend: () -> Unit ) { val palette = AidenTheme.palette @@ -701,14 +821,26 @@ private fun AidenBotSessionComposer( modifier = Modifier.weight(1f) ) Spacer(Modifier.width(8.dp)) - FilledIconButton( - onClick = onSend, - enabled = enabled && draft.isNotBlank(), - shape = AidenShape.Button, - colors = IconButtonDefaults.filledIconButtonColors(containerColor = palette.accent, contentColor = MaterialTheme.colorScheme.onPrimary), - modifier = Modifier.size(AidenUi.MinimumTouchTarget) - ) { - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.bot_session_send)) + if (busy) { + // While the Bot works: Steer joins the reply, Queue sends after it (desktop and iOS + // parity). The menu opens with an empty draft too, so a mode can be picked first. + AidenRunInputSplitButton( + mode = busyMode, + canSubmit = enabled && draft.isNotBlank(), + canChooseMode = enabled, + onSubmit = onSend, + onPickMode = onPickBusyMode + ) + } else { + FilledIconButton( + onClick = onSend, + enabled = enabled && draft.isNotBlank(), + shape = AidenShape.Button, + colors = IconButtonDefaults.filledIconButtonColors(containerColor = palette.accent, contentColor = MaterialTheme.colorScheme.onPrimary), + modifier = Modifier.size(AidenUi.MinimumTouchTarget) + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.bot_session_send)) + } } } } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt index 9cc3c8ce7..ff25e4e46 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -49,6 +50,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import sbtbiswas.AidenOnTheGo.R +import sbtbiswas.AidenOnTheGo.features.chat.AidenPowerSaveScope import sbtbiswas.AidenOnTheGo.features.remote.AidenConnectionState import sbtbiswas.AidenOnTheGo.features.remote.AidenRemoteCoordinator import sbtbiswas.AidenOnTheGo.networking.AidenRemoteClient @@ -219,6 +221,27 @@ fun AidenBotsHomeScreen( onNavigateToBotChat: (String) -> Unit = {}, botDeleter: AidenBotDeleter? = null, onNavigateToBotMemory: (String) -> Unit = onNavigateToBotProfile +) { + // One battery-saver receiver for every glancing face on the page. + AidenPowerSaveScope { + AidenBotsHomeContent( + coordinator, viewModel, onNavigateToChat, onNavigateToBotProfile, onNavigateToCreateBot, + modifier, onNavigateToBotChat, botDeleter, onNavigateToBotMemory + ) + } +} + +@Composable +private fun AidenBotsHomeContent( + coordinator: AidenRemoteCoordinator, + viewModel: AidenBotsViewModel, + onNavigateToChat: (String) -> Unit, + onNavigateToBotProfile: (String) -> Unit, + onNavigateToCreateBot: () -> Unit, + modifier: Modifier, + onNavigateToBotChat: (String) -> Unit, + botDeleter: AidenBotDeleter?, + onNavigateToBotMemory: (String) -> Unit ) { val palette = AidenTheme.palette val resources = LocalResources.current @@ -245,7 +268,16 @@ fun AidenBotsHomeScreen( var advancedClient by remember { mutableStateOf(null) } var openingAdvanced by remember { mutableStateOf(false) } fun openAdvanced(botId: String) { - val cl = client ?: return + // Opens at once from the cached Bot, connected or not (R15); a Bot never seen here is read first. + coordinator.botCache.getBotDetail(botId)?.let { cached -> + advancedClient = client + advancedBot = cached + return + } + val cl = client ?: run { + coordinator.presentError(resources.getString(R.string.bot_session_unreachable)) + return + } if (openingAdvanced) return openingAdvanced = true scope.launch { @@ -261,7 +293,14 @@ fun AidenBotsHomeScreen( throw e } catch (_: Exception) { if (coordinator.holdsReadAuthority(cl, requestInstance)) { - coordinator.presentError(resources.getString(R.string.bot_session_unreachable)) + // Offline: Advanced still opens from the cached Bot, so Chat activity stays reachable. + val cached = coordinator.botCache.getBotDetail(botId) + if (cached != null) { + advancedClient = cl + advancedBot = cached + } else { + coordinator.presentError(resources.getString(R.string.bot_session_unreachable)) + } } } finally { openingAdvanced = false @@ -269,8 +308,11 @@ fun AidenBotsHomeScreen( } } // The sheet belongs to the pairing it was opened in; a switched or revoked client closes it. + // Opened before the Mac connected, it takes the pairing's first client. LaunchedEffect(client) { - if (advancedClient != null && advancedClient !== client) { + if (advancedBot != null && advancedClient == null && client != null) { + advancedClient = client + } else if (advancedClient != null && advancedClient !== client) { advancedBot = null advancedClient = null } @@ -309,6 +351,15 @@ fun AidenBotsHomeScreen( if (client != null && connectionState == AidenConnectionState.CONNECTED) viewModel.loadPresets() } + // New Bot closes at once (R15): the Bot shows here on its way, and its chat opens once the + // Mac made it while this list is on screen. A failure offers Try again with the same answers. + val createFlow = remember(client) { coordinator.botCreateFlow() } + val createHost = remember(client) { coordinator.botSaveHost() } + val creating by createFlow.pending.collectAsStateWithLifecycle() + val createFailure by createFlow.failure.collectAsStateWithLifecycle() + // A New Bot an earlier launch left on its way comes back here, with Try again. + LaunchedEffect(createFlow) { createFlow.restore() } + fun openChat(bot: AidenBotSummary) { if (serverInfo?.supportsBotDurableSession == true) { onNavigateToBotChat(bot.id) @@ -331,6 +382,17 @@ fun AidenBotsHomeScreen( } } + // The Mac's list already shows a Bot that may have been made: nothing is left to try again. + LaunchedEffect(createFailure, allBots) { + val failed = createFailure ?: return@LaunchedEffect + if (aidenBotCreateWasTaken(failed, allBots)) createFlow.settleTaken() + } + + val currentOpenChat by rememberUpdatedState(::openChat) + LaunchedEffect(createFlow) { + createFlow.created.collect { made -> currentOpenChat(made.summary()) } + } + LazyColumn( modifier = modifier.fillMaxSize().background(palette.canvas).aidenReadableWidth(), contentPadding = PaddingValues(bottom = 24.dp) @@ -359,6 +421,9 @@ fun AidenBotsHomeScreen( } } } + creating?.let { pending -> + item(key = "creating") { AidenBotPendingCreateRow(pending.name, reduceMotion) } + } when (contentState) { AidenBotsHomeContentState.LOADING -> item(key = "loading") { AidenBotHomeSkeleton(reduceMotion) } AidenBotsHomeContentState.ERROR -> item(key = "error") { @@ -370,7 +435,8 @@ fun AidenBotsHomeScreen( action = { AidenPrimaryButton(text = stringResource(R.string.action_try_again), onClick = { viewModel.loadBots(force = true) }) } ) } - AidenBotsHomeContentState.EMPTY -> item(key = "empty") { + // The first Bot on its way is the list for now. + AidenBotsHomeContentState.EMPTY -> if (creating == null) item(key = "empty") { val connected = connectionState == AidenConnectionState.CONNECTED if (connected && presets.isNotEmpty()) { AidenMeetYourFirstBot( @@ -434,12 +500,16 @@ fun AidenBotsHomeScreen( advancedBot = null advancedClient = null }, - onSaved = { saved -> - coordinator.botCache.putBotDetail(saved) + // Save applies at once (R15); the list rereads when the Mac answers. + onSaved = { advancedBot = null advancedClient = null - scope.launch { viewModel.loadBots(force = true) } - } + }, + saveHost = remember(advancedClient) { coordinator.botSaveHost() }, + onSaveSettled = { viewModel.loadBots(force = true) }, + onSaveFailed = coordinator::presentError, + chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true, cachedSupport = coordinator.cachedBotActivitySupported), + chatActivityHost = remember(advancedClient) { coordinator.botChatActivityHost() } ) } @@ -459,6 +529,28 @@ fun AidenBotsHomeScreen( } ) } + createFailure?.let { failure -> + AlertDialog( + onDismissRequest = createFlow::dismissFailure, + title = { Text(stringResource(R.string.bot_create_failed_title)) }, + text = { Text(stringResource(failure.message)) }, + confirmButton = { + TextButton(onClick = { + // An earlier launch's create goes out again as it was; otherwise New Bot reopens + // with the answers. A flow whose pairing is no longer in use does neither. + val again = createFlow.retry() + when { + again != null -> createHost.launch { createFlow.send(again) } + createFlow.reopening() != null -> onNavigateToCreateBot() + } + }) { Text(stringResource(R.string.action_try_again), color = palette.accent) } + }, + dismissButton = { + TextButton(onClick = createFlow::dismissFailure) { Text(stringResource(R.string.action_ok), color = palette.accent) } + }, + containerColor = palette.raised + ) + } deleteError?.let { message -> AlertDialog( onDismissRequest = { deleteError = null }, @@ -469,6 +561,39 @@ fun AidenBotsHomeScreen( } } +/** A Bot on its way to the Mac (R15): its name at once, a placeholder face, and "Creating…". */ +@Composable +private fun AidenBotPendingCreateRow(name: String, reduceMotion: Boolean) { + val palette = AidenTheme.palette + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + .semantics(mergeDescendants = true) {} + ) { + AidenBotSkeletonBlock(52.dp, 52.dp, CircleShape, reduceMotion) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = palette.foreground, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(3.dp)) + Text( + text = stringResource(R.string.bot_editor_creating), + style = MaterialTheme.typography.bodyMedium, + color = palette.secondary, + maxLines = 1 + ) + } + } +} + @Composable private fun AidenBotSearchField(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) { val palette = AidenTheme.palette @@ -534,27 +659,16 @@ private fun AidenBotHomeRowView( ) .padding(horizontal = 12.dp, vertical = 12.dp) ) { - Box { + // A working Bot's face glances (or wears the glance badge); the row says "Working". + Box(Modifier.semantics { if (row.isWorking) stateDescription = workingLabel }) { AidenBotCanonicalAvatarView( coordinator = coordinator, botId = row.bot.id, avatar = row.bot.avatar, name = row.bot.name, - size = 52.dp + size = 52.dp, + working = row.isWorking ) - if (row.isWorking) { - Box( - Modifier - .align(Alignment.BottomEnd) - .size(14.dp) - .clip(CircleShape) - .background(palette.canvas) - .padding(2.dp) - .clip(CircleShape) - .background(palette.success) - .semantics { contentDescription = workingLabel } - ) - } } Spacer(Modifier.width(14.dp)) Column(Modifier.weight(1f)) { @@ -638,7 +752,7 @@ private fun AidenBotHomeRowView( if (canDelete) { HorizontalDivider() DropdownMenuItem( - text = { Text(stringResource(R.string.action_delete), color = palette.danger) }, + text = { Text(stringResource(R.string.action_delete), color = aidenBotErrorTextColor()) }, leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null, tint = palette.danger) }, onClick = { menuOpen = false diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsViewModel.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsViewModel.kt index ecaed8a7c..f7c964b05 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsViewModel.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import sbtbiswas.AidenOnTheGo.features.remote.AidenRemoteCoordinator import sbtbiswas.AidenOnTheGo.features.remote.AidenConnectionState @@ -21,6 +22,19 @@ import sbtbiswas.AidenOnTheGo.models.* import sbtbiswas.AidenOnTheGo.persistence.AidenBotCache import sbtbiswas.AidenOnTheGo.networking.AidenRemoteClient +/** The Bots-list row for a Bot the Mac just sent in full. */ +fun AidenBotDetail.summary(): AidenBotSummary = AidenBotSummary( + id = id, + name = name, + purpose = purpose, + avatar = avatar, + health = health, + sessionState = sessionState, + createdAt = createdAt, + updatedAt = updatedAt, + revision = revision +) + class AidenBotsViewModel( val coordinator: AidenRemoteCoordinator, val botCache: AidenBotCache? = null @@ -57,6 +71,15 @@ class AidenBotsViewModel( if (client != null && state == AidenConnectionState.CONNECTED) loadBots() } } + // A Bot made after New Bot closed (R15) joins the list, whichever screen is showing. + viewModelScope.launch { + coordinator.client.collectLatest { + coordinator.botCreateFlow().created.collect { made -> + acceptBot(made.summary()) + loadBots(force = true) + } + } + } } fun loadBots(force: Boolean = false) { diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenComposerView.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenComposerView.kt index 38b5e48dd..d15d8105f 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenComposerView.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenComposerView.kt @@ -605,7 +605,7 @@ private val runInputModeOptions = listOf( * of the two segments, an announced action label, and a springing mode label. */ @Composable -private fun AidenRunInputSplitButton( +internal fun AidenRunInputSplitButton( mode: AidenStreamInputMode, canSubmit: Boolean, canChooseMode: Boolean, @@ -649,9 +649,10 @@ private fun AidenRunInputSplitButton( val primaryInteraction = remember { MutableInteractionSource() } val menuInteraction = remember { MutableInteractionSource() } + // At least a touch target tall, and taller when large text needs it: the label never clips. Row( horizontalArrangement = Arrangement.spacedBy(AidenShape.GroupGap), - modifier = Modifier.height(height) + modifier = Modifier.height(IntrinsicSize.Min).heightIn(min = height) ) { Surface( shape = primaryShape, diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenImageGenerationPlaceholder.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenImageGenerationPlaceholder.kt index 25b34daab..dd0182892 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenImageGenerationPlaceholder.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenImageGenerationPlaceholder.kt @@ -26,6 +26,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -281,9 +283,26 @@ internal object AidenImageGenerationPlaceholderState { kind == AidenAgentStep.Kind.TOOL && toolName == TOOL_NAME } +/** Battery saver for a whole screen, from one receiver; null where no screen provides it. */ +internal val LocalAidenPowerSave = compositionLocalOf { null } + +/** + * Gives [content] one battery-saver receiver to share, so a screen of glancing faces and + * skeletons registers one, not one per avatar. + */ +@Composable +internal fun AidenPowerSaveScope(content: @Composable () -> Unit) { + val powerSave = aidenRegisteredPowerSaveMode() + CompositionLocalProvider(LocalAidenPowerSave provides powerSave, content = content) +} + +/** True while Android battery saver is on: the screen's shared value, or its own receiver outside one. */ +@Composable +internal fun aidenPowerSaveMode(): Boolean = LocalAidenPowerSave.current ?: aidenRegisteredPowerSaveMode() + /** True while Android battery saver is on; tracks changes while composed. */ @Composable -internal fun aidenPowerSaveMode(): Boolean { +private fun aidenRegisteredPowerSaveMode(): Boolean { val context = LocalContext.current val powerManager = remember(context) { context.getSystemService(PowerManager::class.java) } var powerSave by remember { mutableStateOf(powerManager?.isPowerSaveMode == true) } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt index 318096ad4..1c3ef7f33 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import sbtbiswas.AidenOnTheGo.features.bots.AidenBotSessionStore import sbtbiswas.AidenOnTheGo.intents.AidenIntentCatalogStore import sbtbiswas.AidenOnTheGo.intents.AidenIntentInstallationRecord import sbtbiswas.AidenOnTheGo.intents.AidenIntentWorkspaceRecord @@ -32,6 +33,51 @@ import okhttp3.OkHttpClient import java.io.File import java.util.UUID +/** + * The opt-in grants this phone asks a Mac for, each only from a Mac that announces its + * feature, in this order. Older Macs never announce the newer features, so they never see the + * phone-scoped subsets requested. + */ +private val AIDEN_PROGRESS_GRANTS: List Boolean, List>> = listOf( + AidenServer::supportsChatTasks to listOf(AidenRemoteCapability.TASKS_READ), + AidenServer::supportsChatAgents to listOf(AidenRemoteCapability.AGENTS_READ), + AidenServer::supportsQuestionPrompts to listOf(AidenRemoteCapability.QUESTIONS_RESPOND), + AidenServer::supportsChatSkills to listOf(AidenRemoteCapability.SKILLS_INVOKE), + // Contract revision 24: the phone-scoped run subset. + AidenServer::supportsPhoneRunControl to AidenRemoteCapability.PHONE_RUNS, + // Contract revision 26: the phone simulator grant. + AidenServer::supportsMobileSimulators to AidenRemoteCapability.PHONE_SIMULATORS, + // Contract revision 27: memory and routine proposal cards. + AidenServer::offersBotCards to AidenRemoteCapability.PHONE_BOT_CARDS, + // Contract revision 30: activity frames and Chat activity. + AidenServer::offersBotActivity to AidenRemoteCapability.PHONE_BOT_ACTIVITY +) + +/** + * What to ask [server] for: each grant whose feature the Mac announces and that [installation] + * doesn't already hold. A grant left over from an older pairing is never asked for, or trusted, + * without its feature. + */ +internal fun aidenProgressCapabilitiesToRequest( + server: AidenServer, + installation: AidenInstallation +): List = + AIDEN_PROGRESS_GRANTS + .filter { (announced, _) -> announced(server) } + .flatMap { (_, grants) -> grants } + .filterNot { installation.hasNegotiatedAccess(it) } + .distinct() + +/** + * One pairing: the Mac, this phone's device id there, and when it was made. Unpairing and + * pairing the same Mac again is another pairing, so nothing started for the old one lands in + * the new one. + */ +data class AidenPairingIdentity(val instanceId: String, val deviceId: String, val pairedAt: java.time.Instant) + +val AidenInstallation.pairing: AidenPairingIdentity + get() = AidenPairingIdentity(instanceId, deviceId, createdAt) + enum class AidenConnectionState { NEEDS_PAIRING, CONNECTING, @@ -46,13 +92,21 @@ class AidenRemoteCoordinator( private val draftStore: AidenChatDraftStore = AidenChatDraftStore(storageDir), private val navigationStore: AidenProductNavigationStore = AidenProductNavigationStore(storageDir), private val intentCatalogStore: AidenIntentCatalogStore? = null, - private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job()) + private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job()), + /** Where unsent Bot messages wait on disk; the app passes `noBackupFilesDir` so they never reach a backup. */ + botOutboxDir: File = storageDir, + /** Runs the Bot outbox's disk work in order; null uses its own thread. Tests hold it to order a read against a switch. */ + botOutboxIo: java.util.concurrent.Executor? = null ) { val archiveStore = AidenWorkspaceArchiveStore(storageDir) val workspaceCache = AidenWorkspaceEnvironmentCache(File(storageDir, "workspace_cache")) val scheduledCache = AidenScheduledTaskCache(File(storageDir, "scheduled_tasks_cache")) val usageCache = AidenUsageCache(File(storageDir, "usage_cache")) val botCache = AidenBotCache(storageDir) + /** Bot chats' last transcripts and unsent messages (R14, R17), purged with the pairing. */ + val botSessionStore = File(botOutboxDir, "BotOutbox-v1").let { dir -> + botOutboxIo?.let { AidenBotSessionStore(dir, io = it) } ?: AidenBotSessionStore(dir) + } val modelPreferenceStore = AidenModelPreferenceStore(storageDir) val readSnapshotCache = AidenReadSnapshotCache(File(storageDir, "read_snapshots")) private val workspaceListSerializer = ListSerializer(AidenWorkspace.serializer()) @@ -71,6 +125,9 @@ class AidenRemoteCoordinator( private val _client = MutableStateFlow(null) val client: StateFlow = _client.asStateFlow() + /** The client [refreshClient] last made, with the pairing it was made for. */ + @Volatile + private var clientOwner: Pair? = null private val _workspaces = MutableStateFlow>(emptyList()) val workspaces: StateFlow> = _workspaces.asStateFlow() @@ -103,6 +160,81 @@ class AidenRemoteCoordinator( val activeInstanceId: String? get() = installationStore.activeInstallation?.instanceId + /** The pairing in use now, or null with none. */ + val activePairing: AidenPairingIdentity? + get() = installationStore.activeInstallation?.pairing + + /** True while [pairing] is still the pairing in use. */ + fun holdsPairing(pairing: AidenPairingIdentity?): Boolean = pairing != null && activePairing == pairing + + /** + * The client while it was made for [pairing] and [pairing] is still in use, or null. Right + * after a switch the pairing in use changes before its client does; this never hands one + * pairing's work the other pairing's client. + */ + fun pairedClient(pairing: AidenPairingIdentity?): AidenRemoteClient? { + val (owner, made) = clientOwner ?: return null + return made.takeIf { owner == pairing && holdsPairing(pairing) && _client.value === made } + } + + /** + * Writes a Bot detail to the cache only while the pairing that was active when this was + * made still is, so a save that finishes after an unpair or a re-pair never lands in + * another pairing's cache. + */ + fun pinnedBotCache(): (AidenBotDetail) -> Unit { + val pairing = activePairing + return { detail -> if (holdsPairing(pairing)) botCache.putBotDetail(detail) } + } + + /** + * Shows a Bot detail the Mac hasn't confirmed (an edit on its way, or a rollback) on every + * screen without writing it to disk, pinned to the pairing like [pinnedBotCache]. + */ + fun pinnedBotShow(): (AidenBotDetail) -> Unit { + val pairing = activePairing + return { detail -> if (holdsPairing(pairing)) botCache.showBotDetail(detail) } + } + + /** + * Runs [block] on the coordinator's own scope, which outlives every screen. For work a + * person started and may leave behind: a settings pick still saving after they back out. + */ + fun launchDetached(block: suspend () -> Unit): Job { + val entry = DetachedWork(activePairing) + val job = scope.launch { block() } + entry.job = job + detachedWork += entry + job.invokeOnCompletion { detachedWork.remove(entry) } + return job + } + + private class DetachedWork(val pairing: AidenPairingIdentity?) { + lateinit var job: Job + } + + private val detachedWork = java.util.concurrent.CopyOnWriteArrayList() + + /** Work started for one pairing doesn't run on for another, or for none. */ + private fun cancelDetachedWork(keep: (AidenPairingIdentity?) -> Boolean) { + detachedWork.filterNot { keep(it.pairing) }.forEach { it.job.cancel() } + } + + private val pairingMemos = mutableMapOf, Any>() + + /** One [create]d object per pairing and [key], for state that must be shared across screens. */ + @Suppress("UNCHECKED_CAST") + fun pairingMemo(key: String, create: () -> T): T = + pairingMemos.getOrPut(activePairing to key) { create() } as T + + /** + * Whether the active pairing's Mac last announced `bot-activity-v1` and this phone holds + * `bot:activity` (R21: the feature and the grant, both persisted). It stands in until + * `/server` answers, so an offline open keeps the line, Steer and Queue, and Chat activity. + */ + val cachedBotActivitySupported: Boolean + get() = installationStore.activeInstallation?.cachedBotActivitySupported == true + /** * True while [client] is still the active client of installation [instanceId]. A read * started before a pairing was removed, revoked, or switched must check this right before @@ -145,7 +277,9 @@ class AidenRemoteCoordinator( activationGeneration += 1 val generation = activationGeneration val installation = installationStore.activeInstallation + cancelDetachedWork { it != null && it == installation?.pairing } if (installation == null) { + clientOwner = null _client.value = null _serverInfo.value = null _workspaces.value = emptyList() @@ -156,6 +290,7 @@ class AidenRemoteCoordinator( val credential = installationStore.getCredential(installation) if (credential == null) { + clientOwner = null _client.value = null _serverInfo.value = null _workspaces.value = emptyList() @@ -166,6 +301,7 @@ class AidenRemoteCoordinator( val newClient = AidenRemoteClient(installation, credential, pinnedHttpClient(installation)) botCache.activate(installation.instanceId, installation.deviceId) + clientOwner = installation.pairing to newClient _client.value = newClient _serverInfo.value = null // The last Workspace list renders at once while the desktop is reached. @@ -186,7 +322,8 @@ class AidenRemoteCoordinator( instanceId = installation.instanceId, serverCapabilities = serverCapabilities, serverName = server.name, - deviceCapabilities = server.capabilities + deviceCapabilities = server.capabilities, + serverFeatures = server.features ) val negotiatedServer = negotiateProgressCapabilities( generation = generation, @@ -199,7 +336,8 @@ class AidenRemoteCoordinator( instanceId = installation.instanceId, serverCapabilities = negotiatedServer.serverCapabilities ?: negotiatedServer.capabilities, serverName = negotiatedServer.name, - deviceCapabilities = negotiatedServer.capabilities + deviceCapabilities = negotiatedServer.capabilities, + serverFeatures = negotiatedServer.features ) // Persist the negotiated grant before publishing serverInfo; // progress consumers use both values as one capability gate. @@ -232,41 +370,7 @@ class AidenRemoteCoordinator( server: AidenServer ): AidenServer { if (!isCurrent(generation, installation.id, client)) return server - val requested = buildList { - if (server.supportsChatTasks && !installation.hasNegotiatedAccess(AidenRemoteCapability.TASKS_READ)) { - add(AidenRemoteCapability.TASKS_READ) - } - if (server.supportsChatAgents && !installation.hasNegotiatedAccess(AidenRemoteCapability.AGENTS_READ)) { - add(AidenRemoteCapability.AGENTS_READ) - } - if (server.supportsQuestionPrompts && !installation.hasNegotiatedAccess(AidenRemoteCapability.QUESTIONS_RESPOND)) { - add(AidenRemoteCapability.QUESTIONS_RESPOND) - } - if (server.supportsChatSkills && !installation.hasNegotiatedAccess(AidenRemoteCapability.SKILLS_INVOKE)) { - add(AidenRemoteCapability.SKILLS_INVOKE) - } - // Contract revision 24: older Macs never advertise the feature, so - // they never see the phone-scoped run subset requested. - if (server.supportsPhoneRunControl) { - for (capability in AidenRemoteCapability.PHONE_RUNS) { - if (!installation.hasNegotiatedAccess(capability)) add(capability) - } - } - // Contract revision 26: only Macs with the simulator feature are - // asked for the phone simulator grant. - if (server.supportsMobileSimulators) { - for (capability in AidenRemoteCapability.PHONE_SIMULATORS) { - if (!installation.hasNegotiatedAccess(capability)) add(capability) - } - } - // Contract revision 27: this phone renders memory and routine proposal - // cards. Older Macs never advertise the features and are never asked. - if (server.offersBotCards) { - for (capability in AidenRemoteCapability.PHONE_BOT_CARDS) { - if (!installation.hasNegotiatedAccess(capability)) add(capability) - } - } - } + val requested = aidenProgressCapabilitiesToRequest(server, installation) if (requested.isEmpty()) return server return try { val completeCapabilities = client.updateDeviceCapabilities(requested) @@ -285,7 +389,8 @@ class AidenRemoteCoordinator( instanceId = installation.instanceId, serverCapabilities = refreshedServerCapabilities, serverName = refreshed.name, - deviceCapabilities = refreshed.capabilities + deviceCapabilities = refreshed.capabilities, + serverFeatures = refreshed.features ) installationStore.updateDeviceCapabilities(installation.instanceId, completeCapabilities) refreshed @@ -344,12 +449,15 @@ class AidenRemoteCoordinator( .orEmpty() ) } + cancelDetachedWork { it?.instanceId != installation.instanceId } + pairingMemos.keys.removeAll { it.first?.instanceId == installation.instanceId } archiveStore.purge(installation.instanceId) workspaceCache.purge(installation.instanceId, knownWorkspaceIds) scheduledCache.purge(installation.instanceId) usageCache.purge(installation.instanceId) readSnapshotCache.purge(installation.instanceId) botCache.purge(installation.instanceId, installation.deviceId) + botSessionStore.purgeInstance(installation.instanceId) chatCache.purge(installation.instanceId) draftStore.purge(installation.instanceId) modelPreferenceStore.purge(installation.instanceId) diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBot.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBot.kt index 9e1457f17..39b76338c 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBot.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBot.kt @@ -62,6 +62,11 @@ object AidenBotWire { } } + private val SUBMISSION_ID = Regex("^[A-Za-z0-9._:-]{1,64}$") + + /** A submission id the Mac minted: 1 to 64 of `A-Za-z0-9._:-`. */ + fun isSubmissionId(value: String): Boolean = SUBMISSION_ID.matches(value) + fun validateString(value: String, field: String, maxLength: Int, allowEmpty: Boolean = false) { if ((!allowEmpty && value.isEmpty()) || value.codePointCount(0, value.length) > maxLength) { throw AidenBotContractException.InvalidField(field) @@ -323,6 +328,31 @@ object AidenBotDetailSerializer : AidenBotRetiredGreetingSerializer(AidenBotCreateRequest.generatedSerializer()) object AidenBotIdentityPatchSerializer : AidenBotRetiredGreetingSerializer(AidenBotIdentityPatch.generatedSerializer()) +/** A Bot's Chat activity choice (spec §7, contract revision 30). */ +@Serializable +enum class AidenBotChatActivityLevel { + @SerialName("quiet") QUIET, + @SerialName("summary") SUMMARY, + @SerialName("detailed") DETAILED +} + +@Serializable +enum class AidenBotChatActivityWording { + @SerialName("follows") FOLLOWS, + @SerialName("thinking") THINKING +} + +@Serializable +data class AidenBotChatActivity( + val level: AidenBotChatActivityLevel, + val wording: AidenBotChatActivityWording +) { + companion object { + /** What a Bot that never chose shows: Quiet, Follows the work. */ + val DEFAULT = AidenBotChatActivity(AidenBotChatActivityLevel.QUIET, AidenBotChatActivityWording.FOLLOWS) + } +} + @OptIn(ExperimentalSerializationApi::class) @KeepGeneratedSerializer @Serializable(with = AidenBotDetailSerializer::class) @@ -338,6 +368,8 @@ data class AidenBotDetail( val access: AidenBotAccessView, val modelSelection: AidenBotModelSelection? = null, val visionModelSelection: AidenBotModelSelection? = null, + /** Revision 30 (`bot:activity`). */ + val chatActivity: AidenBotChatActivity? = null, @Serializable(with = InstantIso8601Serializer::class) val createdAt: Instant, @Serializable(with = InstantIso8601Serializer::class) val updatedAt: Instant, val revision: String @@ -382,16 +414,28 @@ data class AidenBotIdentityPatch( val name: String? = null, val purpose: String? = null, val instructions: String? = null, - val avatar: AidenBotSemanticAvatar? = null + val avatar: AidenBotSemanticAvatar? = null, + /** Revision 30 (`bot:activity`): how much a running turn shows in the chat. */ + val chatActivity: AidenBotChatActivity? = null ) { init { - if (name == null && purpose == null && instructions == null && avatar == null) { + if (name == null && purpose == null && instructions == null && avatar == null && chatActivity == null) { throw AidenBotContractException.InvalidCombination("empty identity patch") } name?.let { AidenBotWire.validateString(it, "name", AidenBotWire.MAX_NAME_LENGTH) } purpose?.let { AidenBotWire.validateString(it, "purpose", AidenBotWire.MAX_PURPOSE_LENGTH, allowEmpty = true) } instructions?.let { AidenBotWire.validateString(it, "instructions", AidenBotWire.MAX_INSTRUCTIONS_LENGTH) } } + + /** + * The patch a Mac can take: without `chatActivity` unless it announces `bot-activity-v1` and + * this phone holds `bot:activity`. Null when nothing else is left to send. + */ + fun forHost(supportsBotActivity: Boolean): AidenBotIdentityPatch? = when { + supportsBotActivity || chatActivity == null -> this + name == null && purpose == null && instructions == null && avatar == null -> null + else -> copy(chatActivity = null) + } } @Serializable diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBotSession.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBotSession.kt index 89a14709a..5f521b0d1 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBotSession.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBotSession.kt @@ -2,10 +2,13 @@ package sbtbiswas.AidenOnTheGo.models import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer +import kotlinx.serialization.KeepGeneratedSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder @@ -18,6 +21,7 @@ import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject @@ -40,6 +44,17 @@ import java.time.Instant // `routine_proposal` entries (`bot:cards` devices only) and skips entry types this build // can't read instead of refusing the whole session. +/** + * Refuses an optional key the host sent as the literal null: the host leaves an absent value + * out, and TypeScript and iOS refuse `null` (fixture `botActivityPrivacy.rejectedNullLiterals`). + * The wire JSON's `explicitNulls = false` would otherwise read it as absent. + */ +internal fun JsonElement.refusingNullLiterals(field: String, vararg keys: String): JsonElement { + val body = this as? JsonObject ?: return this + for (key in keys) if (body[key] is JsonNull) throw AidenBotContractException.InvalidField("$field.$key") + return this +} + /** Strict codec for the revision-25 Bot DTOs: exact keys, no unknown fields, nulls omitted. */ object AidenBotWireJson { val json: Json = Json { @@ -164,7 +179,9 @@ private fun validateState(state: AidenBotSessionState, interrupted: Boolean, blo sealed class AidenBotSessionEntry { abstract val id: String - @Serializable + @OptIn(ExperimentalSerializationApi::class) + @KeepGeneratedSerializer + @Serializable(with = AidenBotSessionMessageSerializer::class) @SerialName("message") data class Message( override val id: String, @@ -174,7 +191,12 @@ sealed class AidenBotSessionEntry { /** Routine name shown above a routine's user turn. */ val label: String? = null, /** An assistant answer that was cut off. */ - @Serializable(with = AidenStrictBooleanSerializer::class) val interrupted: Boolean? = null + @Serializable(with = AidenStrictBooleanSerializer::class) val interrupted: Boolean? = null, + /** + * Revision 30 (`bot:activity` devices only): the id the Mac gave this person's own + * message when it was sent. It lets a phone settle its optimistic bubble exactly. + */ + val submissionId: String? = null ) : AidenBotSessionEntry() { init { AidenBotWire.validateIdentifier(id, "entry.id") @@ -184,6 +206,12 @@ sealed class AidenBotSessionEntry { if (interrupted != null && (!interrupted || role != AidenBotMessageRole.ASSISTANT)) { throw AidenBotContractException.InvalidField("entry.interrupted") } + // Only the person's own message is tagged, and never a routine turn (which has a label). + submissionId?.let { + if (role != AidenBotMessageRole.USER || label != null || !AidenBotWire.isSubmissionId(it)) { + throw AidenBotContractException.InvalidField("entry.submissionId") + } + } } } @@ -296,6 +324,19 @@ sealed class AidenBotSessionEntry { } } +/** + * A message entry, decoded strictly: `label` and `submissionId` may be left out but never sent + * as null. The discriminator is the sealed class's to read, so it is set aside here. + */ +object AidenBotSessionMessageSerializer : + JsonTransformingSerializer(AidenBotSessionEntry.Message.generatedSerializer()) { + override fun transformDeserialize(element: JsonElement): JsonElement { + element.refusingNullLiterals("entry", "label", "submissionId") + val body = element as? JsonObject ?: return element + return if ("type" in body) JsonObject(body - "type") else body + } +} + @Serializable enum class AidenBotRoutineProposalStatus { @SerialName("pending") PENDING, @@ -434,6 +475,64 @@ data class AidenBotQuestionAnswerReceipt(val waitId: String) { } } +/** + * Contract revision 30 (`bot-activity-v1`, `bot:activity`): what a Bot in a running turn is + * doing. Exactly `phase`, `app` and `startedAt` cross the wire; the strict wire JSON refuses + * any other key. An unknown phase id still decodes and reads as Working. + */ +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = AidenBotSessionActivitySerializer::class) +data class AidenBotSessionActivity( + val phase: String, + val app: String? = null, + /** When the turn started: the Mac's clock, milliseconds since 1970. */ + @Serializable(with = AidenStrictLongSerializer::class) val startedAt: Long +) { + init { + if (!PHASE_ID.matches(phase)) throw AidenBotContractException.InvalidField("activity.phase") + app?.let { AidenBotWire.validateString(it, "activity.app", MAX_APP_LENGTH) } + if (startedAt < 0 || startedAt > AidenRemoteProtocol.MAX_SAFE_INTEGER) { + throw AidenBotContractException.InvalidField("activity.startedAt") + } + } + + companion object { + const val MAX_APP_LENGTH = 40 + private val PHASE_ID = Regex("^[a-z][a-z_]{0,31}$") + } +} + +/** `app` is left out for every phase but `app`, never sent as null. */ +object AidenBotSessionActivitySerializer : + JsonTransformingSerializer(AidenBotSessionActivity.generatedSerializer()) { + override fun transformDeserialize(element: JsonElement): JsonElement = element.refusingNullLiterals("activity", "app") +} + +/** A message waiting for the running turn: STEER joins it, QUEUE runs after it. */ +@Serializable +data class AidenBotQueuedMessage( + val submissionId: String, + val mode: AidenStreamInputMode, + val text: String +) { + init { + if (!AidenBotWire.isSubmissionId(submissionId)) throw AidenBotContractException.InvalidField("queued.submissionId") + AidenBotWire.validateString(text, "queued.text", AidenBotSessionWire.MAX_MESSAGE_LENGTH) + } + + companion object { + const val MAX_COUNT = 20 + + /** At most [MAX_COUNT] messages, each with its own id. */ + fun validateList(queued: List) { + if (queued.size > MAX_COUNT || queued.map { it.submissionId }.toSet().size != queued.size) { + throw AidenBotContractException.InvalidField("queued") + } + } + } +} + /** `GET /bots/{botId}/session`: the newest entries plus the in-flight partial. */ @Serializable(with = AidenBotSessionSerializer::class) data class AidenBotSession( @@ -449,7 +548,11 @@ data class AidenBotSession( /** The question the Bot is waiting on, or null. Always on the wire, as `null` when nothing waits. */ val question: AidenBotQuestion?, /** The tool approval the Bot is waiting on, or null. Always on the wire, as `null` when nothing waits. */ - val approval: AidenBotApproval? + val approval: AidenBotApproval?, + /** Revision 30 (`bot:activity` devices only): what the running turn is doing. */ + val activity: AidenBotSessionActivity? = null, + /** Revision 30 (`bot:activity` devices only): the person's messages still waiting for the turn. */ + val queued: List? = null ) { init { AidenBotWire.validateIdentifier(botId, "botId", AidenRemoteProtocol.MAX_BOT_IDENTIFIER_LENGTH) @@ -460,6 +563,7 @@ data class AidenBotSession( if (entries.size > AidenBotSessionWire.MAX_ENTRIES || entries.map { it.id }.toSet().size != entries.size) { throw AidenBotContractException.InvalidField("entries") } + queued?.let { AidenBotQueuedMessage.validateList(it) } } } @@ -474,7 +578,9 @@ private data class AidenBotSessionFields( val blocked: AidenBotSessionBlock? = null, val partial: String? = null, val entries: List, - @Serializable(with = AidenStrictBooleanSerializer::class) val hasOlder: Boolean + @Serializable(with = AidenStrictBooleanSerializer::class) val hasOlder: Boolean, + val activity: AidenBotSessionActivity? = null, + val queued: List? = null ) /** Writes `question` and `approval` explicitly (`null` when nothing waits) and requires them on read. */ @@ -485,7 +591,7 @@ object AidenBotSessionSerializer : KSerializer { val jsonEncoder = encoder as? JsonEncoder ?: throw SerializationException("Bot sessions are JSON only") val fields = AidenBotSessionFields( value.botId, value.epoch, value.seq, value.state, value.interrupted, - value.blocked, value.partial, value.entries, value.hasOlder + value.blocked, value.partial, value.entries, value.hasOlder, value.activity, value.queued ) val body = jsonEncoder.json.encodeToJsonElement(AidenBotSessionFields.serializer(), fields).jsonObject jsonEncoder.encodeJsonElement( @@ -499,6 +605,8 @@ object AidenBotSessionSerializer : KSerializer { override fun deserialize(decoder: Decoder): AidenBotSession { val jsonDecoder = decoder as? JsonDecoder ?: throw SerializationException("Bot sessions are JSON only") val body = jsonDecoder.decodeJsonElement() as? JsonObject ?: throw AidenBotContractException.InvalidField("session") + // `activity` is null when nothing runs; `queued` is left out, never null. + body.refusingNullLiterals("session", "queued") val question = body["question"] ?: throw AidenBotContractException.InvalidField("question") val approval = body["approval"] ?: throw AidenBotContractException.InvalidField("approval") // Revision 27: entry types this build can't read are dropped; known ones stay strict. @@ -520,7 +628,9 @@ object AidenBotSessionSerializer : KSerializer { entries = fields.entries, hasOlder = fields.hasOlder, question = if (question is JsonNull) null else AidenBotQuestion.parse(question), - approval = if (approval is JsonNull) null else AidenBotApproval.parse(approval) + approval = if (approval is JsonNull) null else AidenBotApproval.parse(approval), + activity = fields.activity, + queued = fields.queued ) } } @@ -538,7 +648,11 @@ data class AidenBotSessionStateView( } @Serializable -data class AidenBotSessionSendRequest(val text: String) { +data class AidenBotSessionSendRequest( + val text: String, + /** Revision 30 (`bot-activity-v1`): Steer joins the running turn, Queue runs after it. */ + val whenBusy: AidenStreamInputMode? = null +) { init { AidenBotWire.validateString(text, "text", AidenBotSessionWire.MAX_MESSAGE_LENGTH) if (text.isBlank()) throw AidenBotContractException.InvalidField("text") @@ -614,6 +728,14 @@ sealed class AidenBotSessionEventPayload { data class Question(val question: AidenBotQuestion?) : AidenBotSessionEventPayload() /** The waiting tool approval appeared (non-null) or was settled or withdrawn (null). */ data class Approval(val approval: AidenBotApproval?) : AidenBotSessionEventPayload() + /** Revision 30: what the running turn is doing now, or null when none runs. */ + data class Activity(val activity: AidenBotSessionActivity?) : AidenBotSessionEventPayload() + /** Revision 30: the person's messages still waiting for the turn. */ + data class Queued(val queued: List) : AidenBotSessionEventPayload() { + init { + AidenBotQueuedMessage.validateList(queued) + } + } /** The host closed this Bot's session; reconnect for a new epoch. */ object Closed : AidenBotSessionEventPayload() { override fun toString(): String = "Closed" @@ -648,6 +770,8 @@ data class AidenBotSessionEvent( is AidenBotSessionEventPayload.State -> "state" is AidenBotSessionEventPayload.Question -> "question" is AidenBotSessionEventPayload.Approval -> "approval" + is AidenBotSessionEventPayload.Activity -> "activity" + is AidenBotSessionEventPayload.Queued -> "queued" AidenBotSessionEventPayload.Closed -> "closed" } } @@ -677,6 +801,12 @@ object AidenBotSessionEventSerializer : KSerializer { is AidenBotSessionEventPayload.Approval -> buildJsonObject { put("approval", p.approval?.toJson() ?: JsonNull) } + is AidenBotSessionEventPayload.Activity -> buildJsonObject { + put("activity", p.activity?.let { wire.encodeToJsonElement(AidenBotSessionActivity.serializer(), it) } ?: JsonNull) + } + is AidenBotSessionEventPayload.Queued -> buildJsonObject { + put("queued", wire.encodeToJsonElement(ListSerializer(AidenBotQueuedMessage.serializer()), p.queued)) + } AidenBotSessionEventPayload.Closed -> JsonObject(emptyMap()) } jsonEncoder.encodeJsonElement(buildJsonObject { @@ -743,6 +873,19 @@ object AidenBotSessionEventSerializer : KSerializer { val element = payload.getValue("approval") AidenBotSessionEventPayload.Approval(if (element is JsonNull) null else AidenBotApproval.parse(element)) } + "activity" -> { + payload.requireKeys(setOf("activity")) + val element = payload.getValue("activity") + AidenBotSessionEventPayload.Activity( + if (element is JsonNull) null else wire.decodeFromJsonElement(AidenBotSessionActivity.serializer(), element) + ) + } + "queued" -> { + payload.requireKeys(setOf("queued")) + AidenBotSessionEventPayload.Queued( + wire.decodeFromJsonElement(ListSerializer(AidenBotQueuedMessage.serializer()), payload.getValue("queued")) + ) + } "closed" -> { payload.requireKeys(emptySet()) AidenBotSessionEventPayload.Closed diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenInstallation.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenInstallation.kt index 1f32ac187..9756e2fb4 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenInstallation.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenInstallation.kt @@ -148,6 +148,17 @@ data class AidenServer( val offersBotCards: Boolean get() = supportsBotMemory || supportsBotProactive + /** + * The Mac offers `bot:activity` (revision 30) in its capability list, so this phone asks for + * it (FR-m3: the same rule as iOS). A Mac offers it only while it announces `bot-activity-v1`. + */ + val offersBotActivity: Boolean + get() = serverCapabilities?.contains(AidenRemoteCapability.BOT_ACTIVITY) == true + + /** Announced and held: show the status line, Steer and Queue, Queued bubbles and Chat activity. */ + val supportsBotActivity: Boolean + get() = features.contains(AidenRemoteProtocol.BOT_ACTIVITY_FEATURE) && capabilities.contains(AidenRemoteCapability.BOT_ACTIVITY) + /** * Chat messages may carry inline `visuals` (revision 28). Informational: * the transcript renders whatever visuals a message carries. @@ -259,10 +270,21 @@ data class AidenInstallation( var deviceCapabilities: List, var serverCapabilities: List? = null, @Serializable(with = InstantIso8601Serializer::class) val createdAt: Instant, - @Serializable(with = InstantIso8601Serializer::class) var lastConnectedAt: Instant? = null + @Serializable(with = InstantIso8601Serializer::class) var lastConnectedAt: Instant? = null, + /** + * The feature tokens the Mac's `/server` last announced to this pairing, or null before it + * first answered. With the persisted grants they stand in for `/server` while it is still on + * its way (R21), so a cold offline start keeps what this pairing could do. + */ + val serverFeatures: List? = null ) { val id: String get() = instanceId + /** The Mac last announced `bot-activity-v1` and this pairing holds `bot:activity` (R21). */ + val cachedBotActivitySupported: Boolean + get() = serverFeatures?.contains(AidenRemoteProtocol.BOT_ACTIVITY_FEATURE) == true && + hasNegotiatedAccess(AidenRemoteCapability.BOT_ACTIVITY) + val isBotsEligible: Boolean get() = hasNegotiatedAccess(AidenRemoteCapability.BOT_READ) diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt index 4eaa65d0d..c1486164b 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt @@ -1378,15 +1378,24 @@ class AidenRemoteClient( decodeBotWire(AidenBotDetail.serializer(), String(bytes, Charsets.UTF_8)) } + /** + * `PATCH /bots/{id}`. The patch's `chatActivity` goes out only when [supportsBotActivity]: + * an older Mac refuses the key, so a patch that changes nothing else is refused here with + * [AidenBotContractException.InvalidCombination] before anything is sent. + */ suspend fun updateBotIdentity( id: String, revision: String, - patch: AidenBotIdentityPatch + patch: AidenBotIdentityPatch, + supportsBotActivity: Boolean = false ): AidenBotDetail = executeRequest( "/bots/$id", method = "PATCH", ifMatchRevision = revision, - bodyJson = botJson.encodeToString(AidenBotIdentityPatch.serializer(), patch), + bodyJson = botJson.encodeToString( + AidenBotIdentityPatch.serializer(), + patch.forHost(supportsBotActivity) ?: throw AidenBotContractException.InvalidCombination("chat activity unsupported") + ), botScope = AidenBotPrivateResponseScope.Root("botDetail") ) { bytes -> val detail = decodeBotWire(AidenBotDetail.serializer(), String(bytes, Charsets.UTF_8)) @@ -1431,10 +1440,21 @@ class AidenRemoteClient( } } - /** Sends one turn. Control writes never auto-retry; a manual retry reuses [idempotencyKey]. */ - suspend fun sendBotMessage(botId: String, text: String, idempotencyKey: UUID): AidenBotSessionSendResponse { + /** + * `POST /bots/{id}/messages`: sends one turn. Control writes never auto-retry; a manual retry + * reuses [idempotencyKey]. [whenBusy] goes out only when [supportsBotActivity] (the Mac + * announces `bot-activity-v1` and this phone holds `bot:activity`): an older Mac refuses the key. + */ + suspend fun sendBotMessage( + botId: String, + text: String, + idempotencyKey: UUID, + whenBusy: AidenStreamInputMode? = null, + supportsBotActivity: Boolean = false + ): AidenBotSessionSendResponse { AidenBotWire.validateIdentifier(botId, "botId", AidenRemoteProtocol.MAX_BOT_IDENTIFIER_LENGTH) - val body = botJson.encodeToString(AidenBotSessionSendRequest.serializer(), AidenBotSessionSendRequest(text)) + val request = AidenBotSessionSendRequest(text, whenBusy.takeIf { supportsBotActivity }) + val body = botJson.encodeToString(AidenBotSessionSendRequest.serializer(), request) return executeRequest( "/bots/$botId/messages", method = "POST", diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenBotCache.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenBotCache.kt index b572d1684..1b991031d 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenBotCache.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenBotCache.kt @@ -102,6 +102,7 @@ class AidenBotCache(private val storageDir: File) { } catch (_: Exception) {} } + /** Keeps a Bot the Mac sent or confirmed, in memory and on disk. */ @Synchronized fun putBotDetail(detail: AidenBotDetail) { val map = _botDetails.value.toMutableMap() @@ -114,6 +115,16 @@ class AidenBotCache(private val storageDir: File) { } catch (_: Exception) {} } + /** + * Shows [detail] on every screen without keeping it: an edit still on its way to the Mac + * (R15). Only [putBotDetail], with what the Mac confirmed, reaches the disk, so a process + * that dies mid-save comes back showing the Mac's Bot, as on iOS. + */ + @Synchronized + fun showBotDetail(detail: AidenBotDetail) { + _botDetails.value = _botDetails.value + (detail.id to detail) + } + @Synchronized fun getBotDetail(id: String): AidenBotDetail? = _botDetails.value[id] diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenInstallationStore.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenInstallationStore.kt index b9614d4b4..c7db409b7 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenInstallationStore.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/persistence/AidenInstallationStore.kt @@ -111,7 +111,9 @@ class AidenInstallationStore( instanceId: String, serverCapabilities: List, serverName: String?, - deviceCapabilities: List + deviceCapabilities: List, + /** The features `/server` announced; null keeps the ones stored. */ + serverFeatures: List? = null ) { val list = _installations.value.toMutableList() val index = list.indexOfFirst { it.instanceId == instanceId } @@ -127,7 +129,8 @@ class AidenInstallationStore( deviceCapabilities = item.deviceCapabilities.filter { refreshedDeviceGrants.contains(it) }, serverCapabilities = serverCapabilities, name = serverName ?: item.name, - lastConnectedAt = Instant.now() + lastConnectedAt = Instant.now(), + serverFeatures = serverFeatures ?: item.serverFeatures ) list[index] = updated _installations.value = list diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteProtocol.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteProtocol.kt index c445ab129..50ec00114 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteProtocol.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteProtocol.kt @@ -69,6 +69,8 @@ object AidenRemoteProtocol { const val BOT_MEMORY_FEATURE = "bot-memory-v1" /** Contract revision 27: routine proposal answers, routine suggestions and the routine notification feed. */ const val BOT_PROACTIVE_FEATURE = "bot-proactive-v1" + /** Contract revision 30: the status line, Steer and Queue, Queued bubbles and Chat activity. */ + const val BOT_ACTIVITY_FEATURE = "bot-activity-v1" /** Contract revision 28: assistant messages may carry inline `visuals`, * each shown through a snapshot image attachment the Mac stores. */ const val CHAT_VISUALS_FEATURE = "chat-visuals-v1" @@ -520,6 +522,11 @@ data class AidenRemoteCapability(val rawValue: String) { * Bot session entries. It grants no authority. */ val BOT_CARDS = AidenRemoteCapability("bot:cards") + /** + * Contract revision 30: this phone receives the activity and queued session frames and + * Bot detail `chatActivity`. It grants no authority. + */ + val BOT_ACTIVITY = AidenRemoteCapability("bot:activity") val V1_KNOWN = listOf( SERVER_READ, CHAT_READ, CHAT_WRITE, APPROVAL_RESPOND, @@ -541,11 +548,14 @@ data class AidenRemoteCapability(val rawValue: String) { /** The Bot card rendering grant, negotiable behind `bot-memory-v1` or `bot-proactive-v1`. */ val PHONE_BOT_CARDS = listOf(BOT_CARDS) + /** Negotiable behind `bot-activity-v1`. */ + val PHONE_BOT_ACTIVITY = listOf(BOT_ACTIVITY) + /** Every grant `POST /device/capabilities` may add. */ - val NEGOTIABLE = PROGRESS + PHONE_RUNS + PHONE_SIMULATORS + PHONE_BOT_CARDS + val NEGOTIABLE = PROGRESS + PHONE_RUNS + PHONE_SIMULATORS + PHONE_BOT_CARDS + PHONE_BOT_ACTIVITY /** The pairing vocabulary plus the negotiable phone-scoped subsets. */ - val PHONE_KNOWN = V1_KNOWN + PHONE_RUNS + PHONE_SIMULATORS + PHONE_BOT_CARDS + val PHONE_KNOWN = V1_KNOWN + PHONE_RUNS + PHONE_SIMULATORS + PHONE_BOT_CARDS + PHONE_BOT_ACTIVITY } } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/ui/theme/AidenSkeleton.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/ui/theme/AidenSkeleton.kt index dd4cd25c9..e714a2f0a 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/ui/theme/AidenSkeleton.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/ui/theme/AidenSkeleton.kt @@ -1,11 +1,5 @@ package sbtbiswas.AidenOnTheGo.ui.theme -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -19,7 +13,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -33,31 +30,51 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import sbtbiswas.AidenOnTheGo.R +private const val SHIMMER_MS = 1_400L + +/** Frame-driven loops (shimmers, glancing faces) redraw at about 30 frames a second. */ +const val AIDEN_CAPPED_FRAME_INTERVAL_MS = 33L + +/** + * Whether a frame [elapsedMs] into a frame-driven loop is due, the last one drawn having been at + * [shownMs]: at most one every [intervalMs]. A clock that went back (a restarted loop) is due. + */ +fun aidenFrameDue(shownMs: Long, elapsedMs: Long, intervalMs: Long = AIDEN_CAPPED_FRAME_INTERVAL_MS): Boolean = + elapsedMs == 0L || elapsedMs - shownMs >= intervalMs || elapsedMs < shownMs + /** * Placeholder block for content that is still arriving. Aiden never shows a spinner for * a read: cached content renders immediately, and only a first load with nothing cached - * shows these shaped placeholders. The shimmer stops under Reduce Motion. + * shows these shaped placeholders. The shimmer stops under Reduce Motion, and when + * [animate] is false (a read that has failed, or an app in the background). */ @Composable fun AidenSkeletonBlock( modifier: Modifier = Modifier, width: Dp? = null, height: Dp = 14.dp, - shape: Shape = MaterialTheme.shapes.small + shape: Shape = MaterialTheme.shapes.small, + animate: Boolean = true ) { val reduceMotion = aidenReduceMotion() val base = MaterialTheme.colorScheme.surfaceContainerHigh - val brush = if (reduceMotion) { + val brush = if (reduceMotion || !animate) { Brush.linearGradient(listOf(base, base)) } else { val highlight = MaterialTheme.colorScheme.surfaceContainerHighest - val transition = rememberInfiniteTransition(label = "aiden_skeleton") - val offset by transition.animateFloat( - initialValue = -300f, - targetValue = 900f, - animationSpec = infiniteRepeatable(tween(1_400, easing = LinearEasing), RepeatMode.Restart), - label = "aiden_skeleton_offset" - ) + // About 30 frames a second: a shimmer needs no more, and a screen of them stays cheap. + val clockMs = remember { mutableLongStateOf(0L) } + LaunchedEffect(Unit) { + var startMs = Long.MIN_VALUE + while (true) { + withFrameMillis { frameMs -> + if (startMs == Long.MIN_VALUE) startMs = frameMs + val elapsed = frameMs - startMs + if (aidenFrameDue(clockMs.longValue, elapsed)) clockMs.longValue = elapsed + } + } + } + val offset = -300f + 1_200f * ((clockMs.longValue % SHIMMER_MS).toFloat() / SHIMMER_MS) Brush.linearGradient( colors = listOf(base, highlight, base), start = Offset(offset, 0f), diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 638b95867..96d993655 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -475,6 +475,7 @@ Name your Bot Aiden couldn’t save. Try again. + Aiden couldn’t reach your Mac to save that. Try again. Delete Bot Connect to your Mac to delete this Bot. Aiden couldn’t delete this Bot. Try again. @@ -491,8 +492,6 @@ Aiden couldn’t load this Bot. Instructions Loading Bot - Connect to your Mac to save changes. - Aiden couldn’t save the new look. Try again. Change photo Photo options Choose from Gallery @@ -573,7 +572,6 @@ Aiden couldn’t save this memory. Try again. Aiden couldn’t delete this memory. Try again. Aiden couldn’t load this memory. Make sure your Mac is on and nearby. - Connect to your Mac to see this memory. Loading memory Erase memory Erase everything %1$s remembers? @@ -604,7 +602,6 @@ Advanced Loading settings - Connect to your Mac to change these settings. Set up an AI model on your Mac first. Aiden couldn’t load these settings. Try again. AI model @@ -619,11 +616,75 @@ Connected apps + + Getting ready + Thinking + Reading + Looking at your photo + Searching the web + Running a command + Editing files + Using an app + Using %1$s + Asking a helper + Using your Mac + Remembering + Setting up a routine + Working + Writing + Waiting for you + Tidying up its notes + Stopping + Picking up where it left off + Reconnecting to your Mac + %1$s · %2$s + + Chat activity + What you see in %1$s’s chat on your Mac while it works. Changes save as you pick them. + How much to show + What the status line says + On iPhone and Android + Always just the line, with the same words. + Recommended + Quiet + One line that says what it’s doing. + Summary + Adds a short recap above each reply. + Detailed + Also shows its last few steps while it works. + Follows the work + Says what it’s doing, like Reading or Searching the web. + Always “Thinking” + One calm word, whatever it’s doing. + %1$s · %2$s + Aiden couldn’t save that choice. + This changed on your Mac. Showing what it has now. + Aiden couldn’t reach your Mac to save that choice. + Preview + Plan dinners for this week + Here’s a plan for the week. + Worked for 18s · read 1 file, searched the web, edited 1 file + Read 1 file + Searched the web + Edited 1 file + Queued + Sends when the current reply finishes + Sending… + Couldn’t send + Try again + + · + That message wasn’t sent. Please try again. + Too many messages are waiting. Send or remove some, then try again. + Some unsent messages couldn’t be restored. + Remove message + Connecting… Open %1$s’s profile Aiden couldn’t open this Bot’s chat. Try again. Connect to your Mac to open this chat. Loading chat This Bot is no longer on your Mac. + This phone can’t open this chat. Check its access in Aiden on your Mac. Aiden couldn’t load this chat. Make sure your Mac is on and nearby. Aiden couldn’t reach your Mac. Try again. This chat was restarted. @@ -652,6 +713,11 @@ New Bot Creating… Aiden couldn’t create this Bot. Try again. + Couldn’t Create Bot + Aiden closed before the Bot was ready. Try again to finish making it. + Aiden closed before your Mac answered, so this Bot may already be there. Check your Bots before you create it again. + Aiden couldn’t save this Bot on your phone, so it wasn’t sent. Try again. + Aiden couldn’t reach your Mac to create this Bot. Try again. What should it help with? Plan my meals and make a grocery list every week That model isn’t available any more. Choose another one. diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt new file mode 100644 index 000000000..f474fc7e3 --- /dev/null +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt @@ -0,0 +1,571 @@ +package sbtbiswas.AidenOnTheGo + +import androidx.annotation.StringRes +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import sbtbiswas.AidenOnTheGo.auth.InMemoryAidenSecureStore +import sbtbiswas.AidenOnTheGo.features.bots.AidenBotActivityPhase +import sbtbiswas.AidenOnTheGo.features.bots.AidenBotSessionEventOutcome +import sbtbiswas.AidenOnTheGo.features.bots.aidenApplyBotSessionEvent +import sbtbiswas.AidenOnTheGo.features.bots.aidenBotActivityDisplay +import sbtbiswas.AidenOnTheGo.features.bots.aidenBotChatActivity +import sbtbiswas.AidenOnTheGo.features.remote.aidenProgressCapabilitiesToRequest +import sbtbiswas.AidenOnTheGo.features.bots.aidenBotElapsedLabel +import sbtbiswas.AidenOnTheGo.features.bots.aidenBotFaceGlance +import sbtbiswas.AidenOnTheGo.features.bots.aidenBotNextAnnouncement +import sbtbiswas.AidenOnTheGo.models.* +import sbtbiswas.AidenOnTheGo.persistence.AidenInstallationStore +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteCapability +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteProtocol +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +/** Contract revision 30: the activity wire, read from the fixture every client shares. */ +class AidenBotActivityContractTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private val json = AidenBotWireJson.json + private val fixture: JsonObject by lazy { + val text = requireNotNull(javaClass.classLoader?.getResourceAsStream("contract.json")) { "contract.json" } + .bufferedReader().use { it.readText() } + json.parseToJsonElement(text).jsonObject + } + private val activity get() = fixture.getValue("botActivity").jsonObject + private val privacy get() = fixture.getValue("botActivityPrivacy").jsonObject + private val started = 1_760_450_000_000L + + /** The English copy in `strings.xml`, by resource name. */ + private val strings: Map by lazy { + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(File("src/main/res/values/strings.xml")) + val nodes = document.getElementsByTagName("string") + (0 until nodes.length).associate { index -> + val element = nodes.item(index) as org.w3c.dom.Element + element.getAttribute("name") to element.textContent + } + } + + private fun english(@StringRes id: Int): String = + strings.getValue(R.string::class.java.fields.first { it.getInt(null) == id }.name) + + /** `count` repetitions of `unit`: the fixture's code-point descriptors, expanded. */ + private fun expand(descriptor: JsonElement): String { + val unit = descriptor.jsonObject.getValue("unit").jsonPrimitive.content + return unit.repeat(descriptor.jsonObject.getValue("count").jsonPrimitive.content.toInt()) + } + + private fun queuedWithText(text: String) = + JsonObject(mapOf("submissionId" to JsonPrimitive("18"), "mode" to JsonPrimitive("queue"), "text" to JsonPrimitive(text))) + + // Test 1 + @Test + fun activitySessionFramesAndDetailDecodeAsTheMacSendsThem() { + val session = json.decodeFromJsonElement(AidenBotSession.serializer(), activity.getValue("session")) + assertEquals(AidenBotSessionActivity("app", "Notion", started), session.activity) + assertEquals(listOf("18", "19"), session.queued?.map { it.submissionId }) + assertEquals(listOf(AidenStreamInputMode.QUEUE, AidenStreamInputMode.STEER), session.queued?.map { it.mode }) + // The session re-encodes as the Mac sent it. + assertEquals(activity.getValue("session"), json.encodeToJsonElement(AidenBotSession.serializer(), session)) + + val events = json.decodeFromJsonElement(ListSerializer(AidenBotSessionEvent.serializer()), activity.getValue("events")) + assertEquals( + listOf( + AidenBotSessionEventPayload.Activity(AidenBotSessionActivity("searching", null, started)), + AidenBotSessionEventPayload.Queued(listOf(AidenBotQueuedMessage("18", AidenStreamInputMode.QUEUE, "Also add a shopping list."))), + AidenBotSessionEventPayload.Activity(AidenBotSessionActivity("teleporting", null, started)), + AidenBotSessionEventPayload.Activity(null), + ), + events.map { it.payload } + ) + // Frames re-encode as the Mac sent them. + assertEquals(activity.getValue("events"), json.encodeToJsonElement(ListSerializer(AidenBotSessionEvent.serializer()), events)) + // The stream's own frame decoder (duplicate keys, privacy scan, identity) reads them the same. + fun streamed(frame: JsonElement) = + AidenBotSessionEventStream.decodeFrame(json.encodeToString(JsonElement.serializer(), frame).toByteArray(), "bot_fixture_01") + assertEquals(events, activity.getValue("events").jsonArray.map(::streamed)) + val tagged = streamed(activity.getValue("entryEvent")).payload as AidenBotSessionEventPayload.Entry + assertEquals("18", (tagged.entry as AidenBotSessionEntry.Message).submissionId) + val nonHolder = streamed(privacy.getValue("nonHolder").jsonObject.getValue("entryEvent")).payload as AidenBotSessionEventPayload.Entry + assertNull("a device without bot:activity never sees the tag", (nonHolder.entry as AidenBotSessionEntry.Message).submissionId) + // A newer Mac's phase decodes and reads as Working. + assertEquals( + aidenBotActivityDisplay("working", AidenBotChatActivityWording.FOLLOWS, null), + aidenBotActivityDisplay("teleporting", AidenBotChatActivityWording.FOLLOWS, null) + ) + + val detail = json.decodeFromJsonElement(AidenBotDetail.serializer(), activity.getValue("detail")) + assertEquals(AidenBotChatActivity(AidenBotChatActivityLevel.SUMMARY, AidenBotChatActivityWording.THINKING), detail.chatActivity) + assertNull(json.decodeFromJsonElement(AidenBotDetail.serializer(), fixture.getValue("botDetail")).chatActivity) + // A revision-29 session re-encodes without the new keys. + val legacy = json.decodeFromJsonElement(AidenBotSession.serializer(), fixture.getValue("botActivityPrivacy").jsonObject.getValue("nonHolder").jsonObject.getValue("session")) + assertNull(legacy.activity) + assertNull(legacy.queued) + assertFalse(json.encodeToJsonElement(AidenBotSession.serializer(), legacy).jsonObject.containsKey("activity")) + } + + // Test 2 + @Test + fun everyPrivacySampleIsRefused() { + for (sample in privacy.getValue("rejectedActivities").jsonArray) { + assertThrows(sample.toString(), Exception::class.java) { + json.decodeFromJsonElement(AidenBotSessionActivity.serializer(), sample) + } + } + for (sample in privacy.getValue("rejectedQueued").jsonArray) { + assertThrows(sample.toString(), Exception::class.java) { + json.decodeFromJsonElement(AidenBotQueuedMessage.serializer(), sample) + } + } + for (sample in privacy.getValue("rejectedChatActivity").jsonArray) { + assertThrows(sample.toString(), Exception::class.java) { + json.decodeFromJsonElement(AidenBotChatActivity.serializer(), sample) + } + } + val session = activity.getValue("session").jsonObject + // A list the Mac would never send is refused whole, through the snapshot and through a frame. + for (list in privacy.getValue("rejectedQueuedLists").jsonArray) { + assertThrows(list.toString(), Exception::class.java) { + json.decodeFromJsonElement(AidenBotSession.serializer(), JsonObject(session + ("queued" to list))) + } + val frame = JsonObject( + mapOf( + "protocolVersion" to JsonPrimitive(1), "botId" to JsonPrimitive("bot_fixture_01"), + "epoch" to JsonPrimitive("epoch_fixture_02"), "seq" to JsonPrimitive(9), + "type" to JsonPrimitive("queued"), "payload" to JsonObject(mapOf("queued" to list)) + ) + ) + assertThrows(list.toString(), Exception::class.java) { + json.decodeFromJsonElement(AidenBotSessionEvent.serializer(), frame) + } + } + assertThrows(Exception::class.java) { + json.decodeFromJsonElement(AidenBotSession.serializer(), JsonObject(session + ("queued" to JsonPrimitive("none")))) + } + // A quoted number is not a number. + assertThrows(Exception::class.java) { + json.decodeFromJsonElement(AidenBotSessionActivity.serializer(), JsonObject(mapOf("phase" to JsonPrimitive("thinking"), "startedAt" to JsonPrimitive("1760450000000")))) + } + // The largest safe integer is a valid start. + assertEquals( + AidenRemoteProtocol.MAX_SAFE_INTEGER, + json.decodeFromJsonElement( + AidenBotSessionActivity.serializer(), + JsonObject(mapOf("phase" to JsonPrimitive("thinking"), "startedAt" to JsonPrimitive(AidenRemoteProtocol.MAX_SAFE_INTEGER))) + ).startedAt + ) + } + + // An optional key sent as the literal null is refused, never read as absent (as TS and iOS do). + @Test + fun everyNullLiteralSampleIsRefused() { + val samples = privacy.getValue("rejectedNullLiterals").jsonObject + fun refused(group: String, decode: (JsonElement) -> Any?) { + val list = samples.getValue(group).jsonArray + assertTrue(group, list.isNotEmpty()) + for (sample in list) assertThrows("$group: $sample", Exception::class.java) { decode(sample) } + } + refused("activities") { json.decodeFromJsonElement(AidenBotSessionActivity.serializer(), it) } + refused("sessions") { json.decodeFromJsonElement(AidenBotSession.serializer(), it) } + refused("events") { json.decodeFromJsonElement(AidenBotSessionEvent.serializer(), it) } + refused("entries") { json.decodeFromJsonElement(AidenBotSessionEntry.serializer(), it) } + // The stream's own frame decoder refuses the frames too. + refused("events") { + AidenBotSessionEventStream.decodeFrame(json.encodeToString(JsonElement.serializer(), it).toByteArray(), "bot_fixture_01") + } + // Left out, the same keys are simply absent. + val session = samples.getValue("sessions").jsonArray[0].jsonObject + assertNull(json.decodeFromJsonElement(AidenBotSession.serializer(), JsonObject(session - "queued")).queued) + val entry = samples.getValue("entries").jsonArray[0].jsonObject + val read = json.decodeFromJsonElement(AidenBotSessionEntry.serializer(), JsonObject(entry - "label")) + assertEquals("17", (read as AidenBotSessionEntry.Message).submissionId) + val app = samples.getValue("activities").jsonArray[0].jsonObject + assertNull(json.decodeFromJsonElement(AidenBotSessionActivity.serializer(), JsonObject(app - "app" + ("phase" to JsonPrimitive("thinking")))).app) + } + + // Test 2b + @Test + fun textBoundsCountCodePointsNotUtf16Units() { + val bounds = privacy.getValue("textBounds").jsonObject + val queuedText = bounds.getValue("queuedText").jsonObject + for (descriptor in queuedText.getValue("rejected").jsonArray) { + assertThrows(descriptor.toString(), Exception::class.java) { + json.decodeFromJsonElement(AidenBotQueuedMessage.serializer(), queuedWithText(expand(descriptor))) + } + } + for (descriptor in queuedText.getValue("accepted").jsonArray) { + val text = expand(descriptor) + assertEquals(text, json.decodeFromJsonElement(AidenBotQueuedMessage.serializer(), queuedWithText(text)).text) + } + val app = bounds.getValue("app").jsonObject + fun activityNamed(name: String) = + JsonObject(mapOf("phase" to JsonPrimitive("app"), "app" to JsonPrimitive(name), "startedAt" to JsonPrimitive(started))) + for (descriptor in app.getValue("rejected").jsonArray) { + assertThrows(descriptor.toString(), Exception::class.java) { + json.decodeFromJsonElement(AidenBotSessionActivity.serializer(), activityNamed(expand(descriptor))) + } + } + for (descriptor in app.getValue("accepted").jsonArray) { + val name = expand(descriptor) + assertEquals(name, json.decodeFromJsonElement(AidenBotSessionActivity.serializer(), activityNamed(name)).app) + } + } + + // Test 2c + @Test + fun theSubmissionIdIsAUserMessageOnlyTag() { + val session = json.decodeFromJsonElement(AidenBotSession.serializer(), activity.getValue("session")) + assertEquals("17", (session.entries.single() as AidenBotSessionEntry.Message).submissionId) + assertEquals(activity.getValue("session"), json.encodeToJsonElement(AidenBotSession.serializer(), session)) + + val entryEvent = json.decodeFromJsonElement(AidenBotSessionEvent.serializer(), activity.getValue("entryEvent")) + val placed = (entryEvent.payload as AidenBotSessionEventPayload.Entry).entry as AidenBotSessionEntry.Message + assertEquals("18", placed.submissionId) + // It is the id the queued message had. + assertEquals(session.queued!![0].submissionId, placed.submissionId) + assertEquals(activity.getValue("entryEvent"), json.encodeToJsonElement(AidenBotSessionEvent.serializer(), entryEvent)) + + // A device without `bot:activity` is sent no tag at all. + val plain = json.decodeFromJsonElement( + AidenBotSessionEvent.serializer(), + privacy.getValue("nonHolder").jsonObject.getValue("entryEvent") + ) + assertNull(((plain.payload as AidenBotSessionEventPayload.Entry).entry as AidenBotSessionEntry.Message).submissionId) + + // An assistant reply, a routine turn (label) and a malformed id are refused. + for (sample in privacy.getValue("rejectedSubmissionIdEntries").jsonArray) { + assertThrows(sample.toString(), Exception::class.java) { + json.decodeFromJsonElement(AidenBotSessionEntry.serializer(), sample) + } + } + } + + // Test 2d: the frames advance the known session in order, and a gap still refetches. + @Test + fun activityAndQueuedFramesFoldIntoTheKnownSession() { + var session = json.decodeFromJsonElement(AidenBotSession.serializer(), activity.getValue("session")) + val events = json.decodeFromJsonElement(ListSerializer(AidenBotSessionEvent.serializer()), activity.getValue("events")) + fun apply(event: AidenBotSessionEvent): AidenBotSession { + val outcome = aidenApplyBotSessionEvent(session, event) as AidenBotSessionEventOutcome.Applied + return outcome.session + } + session = apply(events[0]) + assertEquals(AidenBotSessionActivity("searching", null, started), session.activity) + assertEquals(8L, session.seq) + session = apply(events[1]) + assertEquals(listOf("18"), session.queued?.map { it.submissionId }) + session = apply(events[2]) + assertEquals("teleporting", session.activity?.phase) + session = apply(events[3]) + assertNull(session.activity) + assertEquals(11L, session.seq) + // A frame that skips a sequence number cannot be trusted. + assertTrue(aidenApplyBotSessionEvent(session, events[0].copy(seq = 20)) is AidenBotSessionEventOutcome.Refetch) + } + + // Test 3 + @Test + fun requestsEncodeExactlyTheFixtureBodies() { + val send = activity.getValue("send").jsonObject.getValue("request") + assertEquals(send, json.encodeToJsonElement(AidenBotSessionSendRequest.serializer(), AidenBotSessionSendRequest("Keep it vegetarian.", AidenStreamInputMode.STEER))) + assertEquals( + JsonObject(mapOf("text" to JsonPrimitive("Hi"))), + json.encodeToJsonElement(AidenBotSessionSendRequest.serializer(), AidenBotSessionSendRequest("Hi")) + ) + val patch = activity.getValue("identityPatch").jsonObject.getValue("request") + assertEquals( + patch, + json.encodeToJsonElement( + AidenBotIdentityPatch.serializer(), + AidenBotIdentityPatch(chatActivity = AidenBotChatActivity(AidenBotChatActivityLevel.DETAILED, AidenBotChatActivityWording.FOLLOWS)) + ) + ) + // The patch response is a Bot detail that carries the saved choice. + val saved = json.decodeFromJsonElement(AidenBotDetail.serializer(), activity.getValue("identityPatch").jsonObject.getValue("response")) + assertEquals(AidenBotChatActivity(AidenBotChatActivityLevel.DETAILED, AidenBotChatActivityWording.FOLLOWS), saved.chatActivity) + assertEquals(AidenBotChatActivity(AidenBotChatActivityLevel.QUIET, AidenBotChatActivityWording.FOLLOWS), AidenBotChatActivity.DEFAULT) + } + + // Test 4 + @Test + fun phaseTableIsTheOneTheMacDraws() { + val phases = activity.getValue("phases").jsonArray.map { it.jsonObject } + val keeps = activity.getValue("thinkingKeeps").jsonArray.map { it.jsonPrimitive.content }.toSet() + assertEquals(phases.map { it.getValue("id").jsonPrimitive.content }, AidenBotActivityPhase.entries.map { it.id }) + for (row in phases) { + val id = row.getValue("id").jsonPrimitive.content + val shown = aidenBotActivityDisplay(id, AidenBotChatActivityWording.FOLLOWS, null) + assertEquals(id, shown.phase.id) + assertEquals(id, row.getValue("word").jsonPrimitive.content, english(shown.phase.word)) + assertEquals(id, row.getValue("mark").jsonPrimitive.content, shown.mark.wireName) + val thinking = aidenBotActivityDisplay(id, AidenBotChatActivityWording.THINKING, null) + assertEquals(id, if (id in keeps) id else "thinking", thinking.phase.id) + } + val app = activity.getValue("app").jsonObject + val named = aidenBotActivityDisplay("app", AidenBotChatActivityWording.FOLLOWS, app.getValue("app").jsonPrimitive.content) + assertEquals(app.getValue("word").jsonPrimitive.content, english(R.string.bot_activity_app_named).replace("%1\$s", named.app!!)) + // Only the app phase names an app. + assertNull(aidenBotActivityDisplay("reading", AidenBotChatActivityWording.FOLLOWS, "Notion").app) + assertNull(aidenBotActivityDisplay("app", AidenBotChatActivityWording.FOLLOWS, " ").app) + } + + // Test 5 + @Test + fun elapsedMatchesTheMacIncludingAPhoneWhoseClockIsBehind() { + for (row in activity.getValue("elapsed").jsonArray.map { it.jsonObject }) { + val label = row.getValue("label").let { if (it is JsonNull) null else it.jsonPrimitive.content } + assertEquals( + row.toString(), + label, + aidenBotElapsedLabel(row.getValue("startedAt").jsonPrimitive.longOrNull!!, row.getValue("now").jsonPrimitive.longOrNull!!) + ) + } + // Negative elapsed (a behind clock) never shows, however far behind. + assertNull(aidenBotElapsedLabel(started, started - 3_600_000)) + } + + // Test 6 + @Test + fun screenReadersHearAStatusChangeAtMostEveryTwoSeconds() { + val first = aidenBotNextAnnouncement(null, "Reading", 0) + assertEquals("Reading", first.shown?.text) + val same = aidenBotNextAnnouncement(first.shown, "Reading", 500) + assertEquals(first.shown, same.shown) + val early = aidenBotNextAnnouncement(first.shown, "Searching the web", 1_000) + assertEquals("Reading", early.shown?.text) + assertEquals(1_000L, early.retryInMs) + assertEquals("Searching the web", aidenBotNextAnnouncement(early.shown, "Searching the web", 2_000).shown?.text) + } + + // Test 7 + @Test + fun aWorkingFaceFollowsTheGlanceKeyframes() { + assertEquals(0f, aidenBotFaceGlance(0).dxFraction, 0.0005f) + assertEquals(1f, aidenBotFaceGlance(0).blink, 0.0005f) + assertEquals(-2.6f / 24f, aidenBotFaceGlance(1_280).dxFraction, 0.0005f) + assertEquals(2.6f / 24f, aidenBotFaceGlance(2_400).dxFraction, 0.0005f) + assertEquals(0.1f, aidenBotFaceGlance(3_600).blink, 0.0005f) + } + + // Test 8: the opt-in is requested only from a Mac that offers it. + /** [offers] are the opt-ins the Mac's capability list offers beyond what this phone holds. */ + private fun macWith( + features: List, + capabilities: List, + offers: List = emptyList() + ) = AidenServer( + instanceId = "inst_activity", + name = "Mac", + capabilities = capabilities, + serverCapabilities = (capabilities + offers).distinct(), + features = features + ) + + @Test + fun botActivitySupportNeedsTheFeatureAndTheGrant() { + val feature = AidenRemoteProtocol.BOT_ACTIVITY_FEATURE + val grant = AidenRemoteCapability.BOT_ACTIVITY + val base = listOf(AidenRemoteCapability.CHAT_READ) + // offers: the Mac's capability list lists the opt-in (FR-m3, as on iOS). + // supports: the feature is announced and this phone holds the grant. + val none = macWith(emptyList(), base) + assertFalse(none.offersBotActivity) + assertFalse(none.supportsBotActivity) + val featureOnly = macWith(listOf(feature), base) + assertFalse("announced, but not offered to this phone", featureOnly.offersBotActivity) + assertFalse(featureOnly.supportsBotActivity) + val offered = macWith(listOf(feature), base, offers = listOf(grant)) + assertTrue(offered.offersBotActivity) + assertFalse("offered, not held yet", offered.supportsBotActivity) + // A grant without the feature is never trusted. + val grantOnly = macWith(emptyList(), base + grant) + assertFalse(grantOnly.supportsBotActivity) + val both = macWith(listOf(feature), base + grant) + assertTrue(both.offersBotActivity) + assertTrue(both.supportsBotActivity) + assertEquals("bot-activity-v1", feature) + assertEquals("bot:activity", grant.rawValue) + } + + private fun installationHolding(vararg held: AidenRemoteCapability) = AidenInstallation( + instanceId = "inst_activity", + deviceId = "dev_activity", + name = "Mac", + endpoint = "https://aiden.test/api/aiden/v1", + serverSpkiSha256 = "sha256/test", + deviceCapabilities = listOf(AidenRemoteCapability.CHAT_READ) + held, + serverCapabilities = listOf(AidenRemoteCapability.CHAT_READ) + held, + createdAt = java.time.Instant.parse("2026-10-14T09:00:00Z") + ) + + @Test + fun aGrantIsAskedForOnlyFromAMacThatAnnouncesItsFeatureAndOnlyWhenNotHeld() { + val rows = listOf( + AidenRemoteProtocol.BOT_MEMORY_FEATURE to listOf(AidenRemoteCapability.BOT_CARDS), + AidenRemoteProtocol.BOT_PROACTIVE_FEATURE to listOf(AidenRemoteCapability.BOT_CARDS), + AidenRemoteProtocol.CHAT_AGENTS_FEATURE to listOf(AidenRemoteCapability.AGENTS_READ), + AidenRemoteProtocol.CHAT_QUESTION_PROMPTS_FEATURE to listOf(AidenRemoteCapability.QUESTIONS_RESPOND), + AidenRemoteProtocol.CHAT_SKILLS_FEATURE to listOf(AidenRemoteCapability.SKILLS_INVOKE), + AidenRemoteProtocol.MOBILE_SIMULATORS_FEATURE to listOf(AidenRemoteCapability.SIMULATORS_MOBILE), + AidenRemoteProtocol.PHONE_RUN_CONTROL_FEATURE to listOf(AidenRemoteCapability.RUNS_OBSERVE, AidenRemoteCapability.RUNS_CONTROL), + AidenRemoteProtocol.CHAT_TASKS_FEATURE to listOf(AidenRemoteCapability.TASKS_READ) + ) + for ((feature, grants) in rows) { + val base = listOf(AidenRemoteCapability.CHAT_READ) + // No feature: nothing is asked. + assertEquals(feature, emptyList(), aidenProgressCapabilitiesToRequest(macWith(emptyList(), base), installationHolding())) + // The feature alone: the phone asks for its grant. + assertEquals(feature, grants, aidenProgressCapabilitiesToRequest(macWith(listOf(feature), base), installationHolding())) + // The feature with the grant already held: nothing more is asked. + assertEquals( + feature, + emptyList(), + aidenProgressCapabilitiesToRequest(macWith(listOf(feature), base + grants), installationHolding(*grants.toTypedArray())) + ) + // An older Mac with a grant left over from before and no feature: never asked. + assertEquals( + feature, + emptyList(), + aidenProgressCapabilitiesToRequest(macWith(emptyList(), base + grants), installationHolding(*grants.toTypedArray())) + ) + } + // Several features at once ask for each missing grant once, in a stable order. + val all = macWith( + listOf(AidenRemoteProtocol.BOT_MEMORY_FEATURE, AidenRemoteProtocol.BOT_PROACTIVE_FEATURE, AidenRemoteProtocol.BOT_ACTIVITY_FEATURE), + emptyList(), + offers = listOf(AidenRemoteCapability.BOT_ACTIVITY) + ) + assertEquals( + listOf(AidenRemoteCapability.BOT_CARDS, AidenRemoteCapability.BOT_ACTIVITY), + aidenProgressCapabilitiesToRequest(all, installationHolding()) + ) + } + + // FR-m3: bot:activity is asked for only when the Mac's capability list offers it, as on iOS. + @Test + fun botActivityIsAskedForOnlyWhenTheMacsCapabilityListOffersIt() { + val feature = listOf(AidenRemoteProtocol.BOT_ACTIVITY_FEATURE) + val base = listOf(AidenRemoteCapability.CHAT_READ) + val grant = AidenRemoteCapability.BOT_ACTIVITY + assertEquals( + "the feature alone is not an offer", + emptyList(), + aidenProgressCapabilitiesToRequest(macWith(feature, base), installationHolding()) + ) + assertEquals(listOf(grant), aidenProgressCapabilitiesToRequest(macWith(feature, base, offers = listOf(grant)), installationHolding())) + assertEquals( + "already held", + emptyList(), + aidenProgressCapabilitiesToRequest(macWith(feature, base + grant), installationHolding(grant)) + ) + } + + @Test + fun aCachedChatActivityCountsOnlyWhileTheMacSupportsItNow() { + val detail = json.decodeFromJsonElement(AidenBotDetail.serializer(), activity.getValue("detail")) + val grant = AidenRemoteCapability.BOT_ACTIVITY + val feature = AidenRemoteProtocol.BOT_ACTIVITY_FEATURE + assertEquals(detail.chatActivity, aidenBotChatActivity(macWith(listOf(feature), listOf(grant)), detail)) + assertNull("not connected yet", aidenBotChatActivity(null, detail)) + assertNull("an older Mac", aidenBotChatActivity(macWith(emptyList(), listOf(grant)), detail)) + assertNull("the grant was taken away", aidenBotChatActivity(macWith(listOf(feature), emptyList()), detail)) + } + + @Test + fun chatActivityAndWhenBusyGoOnlyToAMacThatTakesThem() { + val pick = AidenBotChatActivity(AidenBotChatActivityLevel.DETAILED, AidenBotChatActivityWording.FOLLOWS) + val both = AidenBotIdentityPatch(name = "Chef", chatActivity = pick) + assertEquals(both, both.forHost(supportsBotActivity = true)) + assertEquals(AidenBotIdentityPatch(name = "Chef"), both.forHost(supportsBotActivity = false)) + assertNull("nothing left for an older Mac", AidenBotIdentityPatch(chatActivity = pick).forHost(supportsBotActivity = false)) + } + + private fun pairedStore(): Pair { + val store = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + val installation = store.addInstallation( + AidenPairingExchange( + instanceId = "inst_activity", + deviceId = "dev_activity", + endpoint = "https://aiden.test/api/aiden/v1", + serverSpkiSha256 = "sha256/test", + credential = "secret_credential", + capabilities = listOf(AidenRemoteCapability.CHAT_READ, AidenRemoteCapability.BOT_READ), + displayName = "Mac" + ), + null + ) + return store to installation + } + + private fun AidenInstallationStore.current(): AidenInstallation = + installations.value.single { it.instanceId == "inst_activity" } + + @Test + fun theBotActivityGrantPersistsAndIsDroppedWhenTheMacStopsGrantingIt() { + val (store, installation) = pairedStore() + val base = listOf(AidenRemoteCapability.CHAT_READ, AidenRemoteCapability.BOT_READ) + val granted = base + AidenRemoteCapability.BOT_ACTIVITY + // The Mac announces it can grant bot:activity, then the phone is granted it. + store.updateServerCapabilities(installation.instanceId, granted, "Mac", base) + assertFalse(store.current().hasNegotiatedAccess(AidenRemoteCapability.BOT_ACTIVITY)) + store.updateDeviceCapabilities(installation.instanceId, granted) + assertTrue(store.current().hasNegotiatedAccess(AidenRemoteCapability.BOT_ACTIVITY)) + + // It survives a relaunch. + val reloaded = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + assertTrue(reloaded.current().hasNegotiatedAccess(AidenRemoteCapability.BOT_ACTIVITY)) + + // A Mac that stops granting it (an older build, or access revoked) takes it away, + // and the choice does not come back after another relaunch. + reloaded.updateServerCapabilities(installation.instanceId, base, "Mac", base) + assertFalse(reloaded.current().hasNegotiatedAccess(AidenRemoteCapability.BOT_ACTIVITY)) + assertEquals(base, reloaded.current().deviceCapabilities) + val relaunched = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + assertFalse(relaunched.current().hasNegotiatedAccess(AidenRemoteCapability.BOT_ACTIVITY)) + } + + @Test + fun eachPairedMacKeepsOrDropsItsOwnBotActivityGrant() { + val (store, first) = pairedStore() + val second = store.addInstallation( + AidenPairingExchange( + instanceId = "inst_other", + deviceId = "dev_other", + endpoint = "https://other.test/api/aiden/v1", + serverSpkiSha256 = "sha256/other", + credential = "other_credential", + capabilities = listOf(AidenRemoteCapability.CHAT_READ, AidenRemoteCapability.BOT_READ), + displayName = "Studio Mac" + ), + null + ) + val base = listOf(AidenRemoteCapability.CHAT_READ, AidenRemoteCapability.BOT_READ) + val granted = base + AidenRemoteCapability.BOT_ACTIVITY + for (installation in listOf(first, second)) { + store.updateServerCapabilities(installation.instanceId, granted, "Mac", base) + store.updateDeviceCapabilities(installation.instanceId, granted) + } + // Only the first Mac stops granting it. + store.updateServerCapabilities(first.instanceId, base, "Mac", base) + val reloaded = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + fun holds(instanceId: String) = + reloaded.installations.value.single { it.instanceId == instanceId }.hasNegotiatedAccess(AidenRemoteCapability.BOT_ACTIVITY) + assertFalse(holds(first.instanceId)) + assertTrue("the other Mac's grant is its own", holds(second.instanceId)) + } +} diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt index 7205aed61..3483e7387 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt @@ -340,4 +340,45 @@ class AidenBotRevision25ClientTest { .map { (it as JsonPrimitive).content } ) } + + @Test + fun theBotActivityGrantIsNegotiatedAndASendCarriesItsMode() = runBlocking { + server.enqueue(MockResponse().setResponseCode(200).setBody( + """{"capabilities":["chat:read","chat:write","bot:read","bot:write","bot:activity"]}""" + )) + val granted = client.updateDeviceCapabilities(listOf(AidenRemoteCapability.BOT_ACTIVITY)) + assertTrue(granted.contains(AidenRemoteCapability.BOT_ACTIVITY)) + assertEquals( + listOf("bot:activity"), + Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject.getValue("accepts").jsonArray + .map { (it as JsonPrimitive).content } + ) + + val activity = fixture.getValue("botActivity").jsonObject.getValue("send") + server.enqueue(ok(activity.jsonObject.getValue("response"), 202)) + val key = UUID.randomUUID() + val receipt = client.sendBotMessage("bot_fixture_01", "Keep it vegetarian.", key, AidenStreamInputMode.STEER, supportsBotActivity = true) + assertEquals("19", receipt.submissionId) + val steer = server.takeRequest() + assertEquals("/api/aiden/v1/bots/bot_fixture_01/messages", steer.path) + assertEquals(key.toString(), steer.getHeader("Idempotency-Key")) + assertEquals(activity.jsonObject.getValue("request"), Json.parseToJsonElement(steer.body.readUtf8())) + + // The session transport sends what the controller froze, whatever the Mac announces now, + // so a retry under the same key is the same request. + server.enqueue(ok(activity.jsonObject.getValue("response"), 202)) + sbtbiswas.AidenOnTheGo.features.bots.AidenRemoteBotSessionTransport(client) + .send("bot_fixture_01", "Keep it vegetarian.", UUID.randomUUID(), AidenStreamInputMode.STEER) + assertEquals(activity.jsonObject.getValue("request"), Json.parseToJsonElement(server.takeRequest().body.readUtf8())) + + // Without a mode, or to a Mac that doesn't take one, the body is the revision-29 body. + for (mode in listOf(null, AidenStreamInputMode.STEER)) { + server.enqueue(ok(activity.jsonObject.getValue("response"), 202)) + client.sendBotMessage("bot_fixture_01", "Hi", UUID.randomUUID(), mode) + assertEquals( + Json.parseToJsonElement("""{"text":"Hi"}"""), + Json.parseToJsonElement(server.takeRequest().body.readUtf8()) + ) + } + } } diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt new file mode 100644 index 000000000..b404bcaec --- /dev/null +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt @@ -0,0 +1,996 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import sbtbiswas.AidenOnTheGo.auth.InMemoryAidenSecureStore +import sbtbiswas.AidenOnTheGo.features.remote.AidenConnectionState +import sbtbiswas.AidenOnTheGo.features.remote.AidenRemoteCoordinator +import sbtbiswas.AidenOnTheGo.persistence.AidenInstallationStore +import sbtbiswas.AidenOnTheGo.models.* +import sbtbiswas.AidenOnTheGo.networking.AidenRemoteClient +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteCapability +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteClientException +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteErrorCode +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteErrorEnvelope +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteProtocol +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class AidenBotChatActivityTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private val json = AidenBotWireJson.json + private val fixture: JsonObject by lazy { + val text = requireNotNull(javaClass.classLoader?.getResourceAsStream("contract.json")) + .bufferedReader().use { it.readText() } + json.parseToJsonElement(text).jsonObject + } + private val bot by lazy { json.decodeFromJsonElement(AidenBotDetail.serializer(), fixture.getValue("botDetail")) } + + private fun choice(level: AidenBotChatActivityLevel, wording: AidenBotChatActivityWording = AidenBotChatActivityWording.FOLLOWS) = + AidenBotChatActivity(level, wording) + + private val quiet = AidenBotChatActivity.DEFAULT + private val summary = choice(AidenBotChatActivityLevel.SUMMARY) + private val detailed = choice(AidenBotChatActivityLevel.DETAILED) + private val summaryThinking = choice(AidenBotChatActivityLevel.SUMMARY, AidenBotChatActivityWording.THINKING) + + private fun detail(revision: String, activity: AidenBotChatActivity?) = bot.copy(revision = revision, chatActivity = activity) + + /** A save the test settles by hand, so it can look at the page while the request is in flight. */ + private class Call(val next: AidenBotChatActivity, val revision: String) { + val result = CompletableDeferred() + } + + private inner class Harness( + initial: AidenBotDetail = detail("rev_1", null), + refresh: (suspend () -> AidenBotDetail)? = null, + waitForConnection: (suspend () -> Boolean)? = null + ) { + val calls = mutableListOf() + val savedRevisions = mutableListOf() + val controller = AidenBotChatActivityController( + initial, + save = { next, revision -> Call(next, revision).also { calls += it }.result.await() }, + refresh = refresh, + onSaved = { savedRevisions += it.revision }, + waitForConnection = waitForConnection + ) + val state get() = controller.state.value + } + + private fun TestScope.pick(controller: AidenBotChatActivityController, next: AidenBotChatActivity): Job = + launch { controller.pick(next) } + + private fun server409() = AidenRemoteClientException.Server( + 409, + AidenRemoteErrorEnvelope.Body(AidenRemoteErrorCode.REVISION_CONFLICT, "The Bot changed.", "req_1", false) + ) + + // Picks apply at once and save in the background. + @Test + fun aPickShowsAtOnceAndSavesOnTheBotsRevision() = runTest { + val h = Harness() + assertEquals(quiet, h.state.value) + pick(h.controller, summary) + runCurrent() + assertEquals("the page shows the pick while the Mac hasn't answered", summary, h.state.value) + assertEquals("the saved choice is still the Mac's", quiet, h.state.saved) + assertTrue(h.state.isSaving) + assertEquals(listOf("rev_1"), h.calls.map { it.revision }) + + h.calls[0].result.complete(detail("rev_2", summary)) + advanceUntilIdle() + assertEquals(summary, h.state.saved) + assertEquals("rev_2", h.state.revision) + assertFalse(h.state.isSaving) + assertEquals(listOf("rev_2"), h.savedRevisions) + + h.controller.pick(summary) + assertEquals("the current choice sends nothing", 1, h.calls.size) + } + + // Taps during a save coalesce: the latest goes out on the revision the first save returned. + @Test + fun tapsDuringASaveCoalesceAndTheLatestGoesOutOnTheReturnedRevision() = runTest { + val h = Harness() + pick(h.controller, summary) + runCurrent() + pick(h.controller, detailed) + pick(h.controller, summaryThinking) + runCurrent() + assertEquals("each tap shows at once", summaryThinking, h.state.value) + assertEquals("only the first request is out", 1, h.calls.size) + + h.calls[0].result.complete(detail("rev_2", summary)) + runCurrent() + assertEquals(listOf(summary, summaryThinking), h.calls.map { it.next }) + assertEquals("the follow-up uses the revision the first save returned", listOf("rev_1", "rev_2"), h.calls.map { it.revision }) + assertEquals("the pick still shows while it is out", summaryThinking, h.state.value) + + h.calls[1].result.complete(detail("rev_3", summaryThinking)) + advanceUntilIdle() + assertEquals(summaryThinking, h.state.saved) + assertEquals(summaryThinking, h.state.value) + assertEquals(listOf("rev_2", "rev_3"), h.savedRevisions) + assertFalse(h.state.isSaving) + } + + // Picking back to the saved value mid-flight: the Mac must end on it too. + @Test + fun pickingBackToTheSavedValueMidFlightLeavesTheMacOnIt() = runTest { + val h = Harness() + pick(h.controller, detailed) + runCurrent() + pick(h.controller, quiet) + runCurrent() + assertEquals(quiet, h.state.value) + + h.calls[0].result.complete(detail("rev_2", detailed)) + runCurrent() + assertEquals("the Mac took the first pick, so the pick back must be sent", listOf(detailed, quiet), h.calls.map { it.next }) + h.calls[1].result.complete(detail("rev_3", quiet)) + advanceUntilIdle() + assertEquals(quiet, h.state.saved) + assertEquals(quiet, h.state.value) + } + + @Test + fun pickingBackAndForthToTheInFlightValueSendsNothingExtra() = runTest { + val h = Harness() + pick(h.controller, detailed) + runCurrent() + pick(h.controller, quiet) + pick(h.controller, detailed) + runCurrent() + h.calls[0].result.complete(detail("rev_2", detailed)) + advanceUntilIdle() + assertEquals("the Mac already holds the last pick", 1, h.calls.size) + assertEquals(detailed, h.state.value) + assertEquals(detailed, h.state.saved) + assertFalse(h.state.isSaving) + } + + // A failed save returns to the saved choice and leaves the revision where it was. + @Test + fun aFailedSaveReturnsToTheSavedChoiceAndSaysSo() = runTest { + val h = Harness() + pick(h.controller, detailed) + runCurrent() + h.calls[0].result.completeExceptionally(IOException("offline")) + advanceUntilIdle() + assertEquals(quiet, h.state.value) + assertTrue(h.state.saveFailed) + assertFalse(h.state.isSaving) + assertEquals("rev_1", h.state.revision) + assertEquals("a failed save tells nobody the Bot changed", emptyList(), h.savedRevisions) + + pick(h.controller, detailed) + runCurrent() + assertFalse("the next pick clears the error", h.state.saveFailed) + assertEquals("a failed save leaves the revision where it was", listOf("rev_1", "rev_1"), h.calls.map { it.revision }) + h.calls[1].result.complete(detail("rev_2", detailed)) + advanceUntilIdle() + assertEquals(detailed, h.state.value) + } + + @Test + fun aFailureDropsTheTapsQueuedBehindItAndShowsTheSavedChoice() = runTest { + val h = Harness() + pick(h.controller, summary) + runCurrent() + pick(h.controller, detailed) + runCurrent() + h.calls[0].result.completeExceptionally(IOException("offline")) + advanceUntilIdle() + assertEquals("nothing queued behind a failure is sent", 1, h.calls.size) + assertEquals(quiet, h.state.value) + assertTrue(h.state.saveFailed) + + h.controller.dismissError() + assertFalse(h.state.saveFailed) + } + + // A stale revision refreshes the Bot, adopts the Mac's choice and reports the error once. + @Test + fun aRevisionConflictAdoptsTheMacsChoiceAndRollsBackToIt() = runTest { + val fresh = detail("rev_9", summaryThinking) + val h = Harness(refresh = { fresh }) + pick(h.controller, detailed) + runCurrent() + h.calls[0].result.completeExceptionally(server409()) + advanceUntilIdle() + + assertEquals("the radio shows what the Mac holds", summaryThinking, h.state.value) + assertEquals(summaryThinking, h.state.saved) + assertEquals("rev_9", h.state.revision) + assertTrue("the person is told once", h.state.saveFailed) + assertEquals("the screens behind hear about the fresh Bot", listOf("rev_9"), h.savedRevisions) + + h.controller.dismissError() + pick(h.controller, detailed) + runCurrent() + assertEquals("the next pick can save", "rev_9", h.calls[1].revision) + assertFalse(h.state.saveFailed) + } + + @Test + fun aConflictWhoseRefreshAlsoFailsStillRollsBack() = runTest { + val h = Harness(refresh = { throw IOException("offline") }) + pick(h.controller, detailed) + runCurrent() + h.calls[0].result.completeExceptionally(server409()) + advanceUntilIdle() + assertEquals(quiet, h.state.value) + assertTrue(h.state.saveFailed) + assertEquals("rev_1", h.state.revision) + assertEquals(emptyList(), h.savedRevisions) + } + + // While connecting, a pick is held for a while, then sent or rolled back. + @Test + fun aPickMadeWhileConnectingIsHeldThenSentOnceTheMacIsUp() = runTest { + val connected = CompletableDeferred() + val h = Harness(waitForConnection = { connected.await() }) + pick(h.controller, summary) + runCurrent() + pick(h.controller, detailed) + runCurrent() + assertEquals("shown at once", detailed, h.state.value) + assertTrue(h.state.isSaving) + assertEquals("nothing is sent before the Mac is up", 0, h.calls.size) + + advanceTimeBy(AidenBotChatActivityController.CONNECTION_HOLD_MILLIS - 100) + runCurrent() + connected.complete(true) + runCurrent() + assertEquals(listOf(summary), h.calls.map { it.next }) + h.calls[0].result.complete(detail("rev_2", summary)) + runCurrent() + assertEquals("the tap made during the hold goes out after it", listOf(summary, detailed), h.calls.map { it.next }) + h.calls[1].result.complete(detail("rev_3", detailed)) + advanceUntilIdle() + assertEquals(detailed, h.state.saved) + } + + @Test + fun aPickHeldPastTheLimitRollsBackWithoutSending() = runTest { + val h = Harness(waitForConnection = { awaitCancellation() }) + pick(h.controller, summary) + runCurrent() + advanceTimeBy(AidenBotChatActivityController.CONNECTION_HOLD_MILLIS - 1) + runCurrent() + assertEquals("still held", summary, h.state.value) + assertTrue(h.state.isSaving) + + advanceTimeBy(2) + runCurrent() + assertEquals(0, h.calls.size) + assertEquals(quiet, h.state.value) + assertTrue(h.state.saveFailed) + assertFalse(h.state.isSaving) + } + + @Test + fun noMacPairedRollsBackAtOnce() = runTest { + val h = Harness(waitForConnection = { false }) + pick(h.controller, summary) + runCurrent() + assertEquals(0, h.calls.size) + assertEquals(quiet, h.state.value) + assertTrue(h.state.saveFailed) + } + + // A request that never answers is bounded, so the page can't hang on a dead link. + @Test + fun aRequestThatNeverAnswersRollsBackAndTheNextPickKeepsTheRevision() = runTest { + val h = Harness() + pick(h.controller, summary) + runCurrent() + advanceTimeBy(AidenBotChatActivityController.REQUEST_LIMIT_MILLIS - 1) + runCurrent() + assertTrue(h.state.isSaving) + assertEquals(summary, h.state.value) + + advanceTimeBy(2) + runCurrent() + assertEquals(quiet, h.state.value) + assertTrue(h.state.saveFailed) + assertFalse(h.state.isSaving) + + pick(h.controller, detailed) + runCurrent() + assertEquals("the same revision", listOf("rev_1", "rev_1"), h.calls.map { it.revision }) + } + + // A save the Mac took is reported even if the caller left meanwhile: the screens behind must hear. + @Test + fun aSaveConfirmedAfterTheCallerLeavesStillReports() = runTest { + val h = Harness() + val job = pick(h.controller, summary) + runCurrent() + job.cancel() + runCurrent() + h.calls[0].result.complete(detail("rev_2", summary)) + advanceUntilIdle() + assertEquals(listOf("rev_2"), h.savedRevisions) + assertEquals(summary, h.state.saved) + assertEquals("rev_2", h.state.revision) + assertEquals("the page shows what the Mac holds", summary, h.state.value) + assertFalse(h.state.isSaving) + assertFalse("leaving is not a failure to report", h.state.saveFailed) + } + + @Test + fun leavingStopsThePicksQueuedBehindTheSaveThatIsOut() = runTest { + val h = Harness() + val job = pick(h.controller, summary) + runCurrent() + pick(h.controller, detailed) + runCurrent() + job.cancel() + runCurrent() + h.calls[0].result.complete(detail("rev_2", summary)) + advanceUntilIdle() + assertEquals("the queued pick is not sent for someone who left", 1, h.calls.size) + assertEquals(listOf("rev_2"), h.savedRevisions) + assertEquals(summary, h.state.value) + assertEquals(summary, h.state.saved) + assertFalse(h.state.isSaving) + } + + // A Mac that applies the pick and then answers too late: the radio ends on the applied value. + @Test + fun aSaveThatLandedButTimedOutShowsAsSaved() = runTest { + val h = Harness(refresh = { detail("rev_2", summary) }) + pick(h.controller, summary) + runCurrent() + advanceTimeBy(AidenBotChatActivityController.REQUEST_LIMIT_MILLIS + 1) + advanceUntilIdle() + assertEquals(summary, h.state.value) + assertEquals(summary, h.state.saved) + assertEquals("rev_2", h.state.revision) + assertFalse("the Mac has the pick, so there is nothing to apologise for", h.state.saveFailed) + assertEquals(listOf("rev_2"), h.savedRevisions) + assertFalse(h.state.isSaving) + } + + @Test + fun aTimeoutWhereTheMacHoldsSomethingElseShowsWhatItHoldsAndSaysItChanged() = runTest { + val h = Harness(refresh = { detail("rev_5", summaryThinking) }) + pick(h.controller, detailed) + runCurrent() + advanceTimeBy(AidenBotChatActivityController.REQUEST_LIMIT_MILLIS + 1) + advanceUntilIdle() + assertEquals(summaryThinking, h.state.value) + assertEquals("rev_5", h.state.revision) + assertEquals(AidenBotChatActivityFailure.CONFLICT, h.state.saveFailure) + } + + @Test + fun aTimeoutWhereTheMacNeverTookThePickSaysOffline() = runTest { + val h = Harness(refresh = { detail("rev_1", null) }) + pick(h.controller, detailed) + runCurrent() + advanceTimeBy(AidenBotChatActivityController.REQUEST_LIMIT_MILLIS + 1) + advanceUntilIdle() + assertEquals(quiet, h.state.value) + assertEquals(AidenBotChatActivityFailure.OFFLINE, h.state.saveFailure) + } + + @Test + fun aTransportFailureAlsoLooksForALateSuccess() = runTest { + val h = Harness(refresh = { detail("rev_2", detailed) }) + pick(h.controller, detailed) + runCurrent() + h.calls[0].result.completeExceptionally(IOException("connection reset")) + advanceUntilIdle() + assertEquals(detailed, h.state.value) + assertEquals(detailed, h.state.saved) + assertFalse(h.state.saveFailed) + } + + @Test + fun aMacThatNeverReceivedThePickIsNotAskedWhatItHolds() = runTest { + var refreshed = false + val h = Harness(refresh = { refreshed = true; detail("rev_2", detailed) }, waitForConnection = { false }) + pick(h.controller, detailed) + runCurrent() + assertFalse(refreshed) + assertEquals(quiet, h.state.value) + } + + @Test + fun theFailureSaysWhetherTheMacRefusedWasUnreachableOrChanged() = runTest { + suspend fun failureOf(error: Exception): AidenBotChatActivityFailure? { + val controller = AidenBotChatActivityController(detail("rev_1", null), save = { _, _ -> throw error }) + controller.pick(detailed) + return controller.state.value.saveFailure + } + assertEquals(AidenBotChatActivityFailure.OFFLINE, failureOf(IOException("offline"))) + assertEquals(AidenBotChatActivityFailure.OFFLINE, failureOf(AidenRemoteClientException.Disconnected())) + assertEquals(AidenBotChatActivityFailure.CONFLICT, failureOf(server409())) + assertEquals( + AidenBotChatActivityFailure.GENERIC, + failureOf(AidenRemoteClientException.Server(404, AidenRemoteErrorEnvelope.Body(AidenRemoteErrorCode.NOT_FOUND, "Gone", "req_2", false))) + ) + } + + // The Mac is the authority on what was saved. + @Test + fun theMacsAnswerWinsOverWhatWasPicked() = runTest { + val h = Harness() + pick(h.controller, detailed) + runCurrent() + h.calls[0].result.complete(detail("rev_2", summary)) + advanceUntilIdle() + assertEquals(summary, h.state.saved) + assertEquals("the radio settles on what the Mac kept", summary, h.state.value) + } + + @Test + fun anAnswerWithNoChoiceMeansThePickWasTaken() = runTest { + val h = Harness() + pick(h.controller, detailed) + runCurrent() + h.calls[0].result.complete(detail("rev_2", null)) + advanceUntilIdle() + assertEquals(detailed, h.state.saved) + assertEquals(detailed, h.state.value) + } + + @Test + fun aRefreshWithNoChoiceMeansTheDefault() = runTest { + val h = Harness(initial = detail("rev_1", detailed), refresh = { detail("rev_9", null) }) + assertEquals(detailed, h.state.value) + pick(h.controller, summary) + runCurrent() + h.calls[0].result.completeExceptionally(server409()) + advanceUntilIdle() + assertEquals(quiet, h.state.saved) + assertEquals(quiet, h.state.value) + assertEquals("rev_9", h.state.revision) + } + + // The connection is checked before every send, not just the first. + @Test + fun theHoldAppliesToTheFollowUpSendToo() = runTest { + var waits = 0 + val h = Harness(waitForConnection = { waits += 1; waits == 1 }) + pick(h.controller, summary) + runCurrent() + pick(h.controller, detailed) + runCurrent() + h.calls[0].result.complete(detail("rev_2", summary)) + advanceUntilIdle() + assertEquals(2, waits) + assertEquals("the follow-up never went out", 1, h.calls.size) + assertEquals("it rolls back to what the Mac confirmed", summary, h.state.value) + assertEquals(AidenBotChatActivityFailure.OFFLINE, h.state.saveFailure) + } + + // The client-bound controller patches with the chat activity opt-in, on the revision it was given. + @Test + fun theClientBoundControllerSendsTheChoiceAsAnIdentityPatch() = runBlocking { + val patched = fixture.getValue("botActivity").jsonObject.getValue("identityPatch").jsonObject + val server = MockWebServer() + server.start() + try { + val client = AidenRemoteClient( + endpoint = server.url("/api/aiden/v1").toString().trimEnd('/'), + credential = "test_credential_123", + customOkHttpClient = OkHttpClient() + ) + val saved = mutableListOf() + val controller = aidenBotChatActivityController(bot.copy(revision = "bot_revision_8"), client = { client }, onSaved = { saved += it }) + server.enqueue(MockResponse().setResponseCode(200).setBody(patched.getValue("response").toString())) + + controller.pick(detailed) + + val request = server.takeRequest() + assertEquals("PATCH", request.method) + assertEquals("bot_revision_8", request.getHeader("If-Match")) + assertEquals(patched.getValue("request"), json.parseToJsonElement(request.body.readUtf8())) + assertEquals(detailed, controller.state.value.saved) + assertEquals("bot_revision_9", controller.state.value.revision) + assertEquals(1, saved.size) + } finally { + server.shutdown() + } + } + + @Test + fun withoutAClientThePickRollsBackWithTheError() = runTest { + val controller = aidenBotChatActivityController(bot, client = { null }) + controller.pick(detailed) + assertEquals(quiet, controller.state.value.value) + assertTrue(controller.state.value.saveFailed) + } + + // The screens behind Advanced hear about picks once, when it closes; a late pick is reported at once. + @Test + fun theScreensBehindHearOnceWithTheNewestBotWhenAdvancedCloses() { + val heard = mutableListOf() + val reporter = AidenBotChatActivityReporter { heard += it.revision } + reporter.saved(detail("rev_2", summary)) + reporter.saved(detail("rev_3", detailed)) + assertEquals("nothing is reported while Advanced is open", emptyList(), heard) + reporter.close() + reporter.close() + assertEquals(listOf("rev_3"), heard) + } + + @Test + fun aPickThatFinishesAfterAdvancedClosedIsReportedAtOnce() { + val heard = mutableListOf() + val reporter = AidenBotChatActivityReporter { heard += it.revision } + reporter.close() + assertEquals("no pick, nothing to report", emptyList(), heard) + reporter.saved(detail("rev_4", summary)) + assertEquals(listOf("rev_4"), heard) + } + + @Test + fun advancedsOwnSaveSettlesThePicksItAlreadyCarries() { + val heard = mutableListOf() + val reporter = AidenBotChatActivityReporter { heard += it.revision } + reporter.saved(detail("rev_2", summary)) + reporter.settle() + reporter.close() + assertEquals(emptyList(), heard) + reporter.saved(detail("rev_3", detailed)) + assertEquals("a later pick still reaches them", listOf("rev_3"), heard) + } + + // The page waits for the Mac without blocking; it gives up only when there is no Mac to wait for. + @Test + fun theHoldEndsWhenTheMacIsUpAndKeepsWaitingThroughOffline() = runTest { + val connection = MutableStateFlow(AidenConnectionState.CONNECTING) + val client = MutableStateFlow(null) + val result = async { aidenBotAwaitConnected(connection, client) } + runCurrent() + assertFalse(result.isCompleted) + connection.value = AidenConnectionState.OFFLINE + runCurrent() + assertFalse(result.isCompleted) + connection.value = AidenConnectionState.CONNECTED + runCurrent() + assertFalse("connected without a client is not yet usable", result.isCompleted) + client.value = "client" + runCurrent() + assertTrue(result.await()) + } + + @Test + fun theHoldGivesUpAtOnceWhenThePhoneNeedsPairing() = runTest { + val connection = MutableStateFlow(AidenConnectionState.NEEDS_PAIRING) + assertFalse(aidenBotAwaitConnected(connection, MutableStateFlow("stale client"))) + } + + // A pick is cached for the screens behind as soon as the Mac confirms it. + @Test + fun aConfirmedPickIsCachedAndReported() = runBlocking { + val patched = fixture.getValue("botActivity").jsonObject.getValue("identityPatch").jsonObject + val server = MockWebServer() + server.start() + try { + val client = AidenRemoteClient( + endpoint = server.url("/api/aiden/v1").toString().trimEnd('/'), + credential = "test_credential_123", + customOkHttpClient = OkHttpClient() + ) + val cached = mutableListOf() + val host = AidenBotChatActivityHost(client = { client }, waitForConnection = { true }, launch = { }, cache = { cached += it }) + val reported = mutableListOf() + server.enqueue(MockResponse().setResponseCode(200).setBody(patched.getValue("response").toString())) + + host.controller(bot.copy(revision = "bot_revision_8")) { reported += it }.pick(detailed) + + assertEquals(listOf("bot_revision_9"), cached.map { it.revision }) + assertEquals(cached, reported) + } finally { + server.shutdown() + } + } + + // --- The pairing pin (coordinator level) --- + + private fun exchange( + instance: String, + capabilities: List = listOf(AidenRemoteCapability.CHAT_READ), + deviceId: String = "device-$instance" + ) = + AidenPairingExchange( + instanceId = instance, + deviceId = deviceId, + endpoint = "https://$instance.test/api/aiden/v1", + serverSpkiSha256 = "sha256/$instance", + credential = "secret-$instance", + capabilities = capabilities + ) + + /** A coordinator whose scope never runs anything, so no request ever leaves. */ + private fun coordinatorFor(installations: AidenInstallationStore) = AidenRemoteCoordinator( + installationStore = installations, + storageDir = tempFolder.root, + scope = CoroutineScope(StandardTestDispatcher() + SupervisorJob()) + ) + + @Test + fun aHostGoesQuietOnceItsPairingChanges() = runBlocking { + val installations = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + installations.addInstallation(exchange("one"), null) + val coordinator = coordinatorFor(installations) + coordinator.refreshClient() + val host = coordinator.botChatActivityHost() + assertSame("one host per pairing", host, coordinator.botChatActivityHost()) + assertNotNull(host.client()) + host.cache(bot) + assertEquals("the pairing's own pick is cached", bot, coordinator.botCache.getBotDetail(bot.id)) + + installations.addInstallation(exchange("two"), null) + coordinator.refreshClient() + + assertNull("no client for the old Mac", host.client()) + assertFalse(host.waitForConnection()) + host.cache(bot.copy(revision = "late_pick")) + assertNull("the old Mac's Bot never reaches the new pairing's cache", coordinator.botCache.getBotDetail(bot.id)) + assertNotSame(host, coordinator.botChatActivityHost()) + + val reported = mutableListOf() + val controller = host.controller(bot) { reported += it } + controller.pick(detailed) + assertEquals("nothing was sent, so nothing was saved", quiet, controller.state.value.saved) + assertEquals(quiet, controller.state.value.value) + assertEquals(AidenBotChatActivityFailure.OFFLINE, controller.state.value.saveFailure) + assertEquals(emptyList(), reported) + } + + @Test + fun detachedWorkStopsWhenItsPairingChangesOrIsRemoved() { + val installations = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + installations.addInstallation(exchange("one"), null) + val coordinator = coordinatorFor(installations) + coordinator.refreshClient() + val first = coordinator.launchDetached { awaitCancellation() } + coordinator.refreshClient() + assertTrue("reconnecting the same pairing keeps a pick going", first.isActive) + + installations.addInstallation(exchange("two"), null) + val second = coordinator.launchDetached { awaitCancellation() } + coordinator.refreshClient() + assertTrue("another pairing is now active", first.isCancelled) + assertTrue("work for the active pairing carries on", second.isActive) + + coordinator.removeInstallation("two") + assertTrue(second.isCancelled) + } + + private val activityGrants = listOf(AidenRemoteCapability.CHAT_READ, AidenRemoteCapability.BOT_READ, AidenRemoteCapability.BOT_ACTIVITY) + + /** What a Mac's `/server` answer leaves on the pairing: its grants and the features it announced. */ + private fun AidenInstallationStore.answered(instance: String, features: List, grants: List = activityGrants) = + updateServerCapabilities(instance, grants, "Mac", grants, serverFeatures = features) + + @Test + fun theCachedSupportIsTheNegotiatedOneForThePairingInUse() { + val installations = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + val coordinator = coordinatorFor(installations) + installations.addInstallation(exchange("one", activityGrants), null) + assertFalse("the grant alone, before /server ever announced the feature", coordinator.cachedBotActivitySupported) + installations.answered("one", listOf(AidenRemoteProtocol.BOT_ACTIVITY_FEATURE)) + assertTrue(coordinator.cachedBotActivitySupported) + installations.addInstallation(exchange("two"), null) + assertFalse("the active pairing never held it", coordinator.cachedBotActivitySupported) + } + + // R21: the feature token is persisted with the grant, so a cold offline start keeps both. + @Test + fun aColdOfflineStartKeepsWhatTheMacLastAnnouncedAndHeldAndAnOlderMacClearsIt() { + val installations = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + installations.addInstallation(exchange("one", activityGrants), null) + installations.answered("one", listOf(AidenRemoteProtocol.BOT_ACTIVITY_FEATURE)) + + val relaunched = coordinatorFor(AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore())) + assertTrue("no /server yet: the persisted feature and grant stand in", relaunched.cachedBotActivitySupported) + assertTrue(aidenBotActivitySupported(relaunched.serverInfo.value, relaunched.cachedBotActivitySupported)) + + // The Mac went back to a build without the feature: the next launch doesn't assume it. + relaunched.installationStore.answered("one", emptyList()) + assertFalse(coordinatorFor(AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore())).cachedBotActivitySupported) + } + + // An unpair and re-pair of the same Mac is another pairing: nothing of the old one carries over. + @Test + fun aRePairedMacStartsWithoutTheOldPairingsGrantHostOrSaves() = runBlocking { + val installations = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + installations.addInstallation(exchange("one", activityGrants), null) + installations.answered("one", listOf(AidenRemoteProtocol.BOT_ACTIVITY_FEATURE)) + val coordinator = coordinatorFor(installations) + coordinator.refreshClient() + assertTrue(coordinator.cachedBotActivitySupported) + val oldHost = coordinator.botChatActivityHost() + val oldWork = coordinator.launchDetached { awaitCancellation() } + + coordinator.removeInstallation("one") + installations.addInstallation(exchange("one", deviceId = "device-one-again"), null) + coordinator.refreshClient() + + assertFalse("the new pairing was never granted bot:activity", coordinator.cachedBotActivitySupported) + assertTrue(oldWork.isCancelled) + assertNotSame(oldHost, coordinator.botChatActivityHost()) + assertNull("the old pairing's host has no client for the new one", oldHost.client()) + oldHost.cache(bot.copy(revision = "late_pick")) + assertNull("a save that finishes late never lands in the new pairing's cache", coordinator.botCache.getBotDetail(bot.id)) + } + + // Re-pairing without an unpair in between (the same Mac id) is another pairing too. + @Test + fun aHostAndItsWorkArePinnedToThePairingNotTheMacsId() { + val installations = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + installations.addInstallation(exchange("one"), null) + val coordinator = coordinatorFor(installations) + coordinator.refreshClient() + val host = coordinator.botChatActivityHost() + val work = coordinator.launchDetached { awaitCancellation() } + installations.addInstallation(exchange("one", deviceId = "device-one-again"), null) + coordinator.refreshClient() + assertTrue(work.isCancelled) + assertNotSame(host, coordinator.botChatActivityHost()) + host.cache(bot) + assertNull(coordinator.botCache.getBotDetail(bot.id)) + // The new pairing's own writes land. + coordinator.pinnedBotCache()(bot) + assertEquals(bot, coordinator.botCache.getBotDetail(bot.id)) + } + + // The host lets go of a closed page's listener, once any save it started has reported. + @Test + fun aClosedPageIsLetGoOfOnceItsSaveHasReported() = runBlocking { + val patched = fixture.getValue("botActivity").jsonObject.getValue("identityPatch").jsonObject + val server = MockWebServer() + server.start() + try { + val client = AidenRemoteClient( + endpoint = server.url("/api/aiden/v1").toString().trimEnd('/'), + credential = "test_credential_123", + customOkHttpClient = OkHttpClient() + ) + server.enqueue(MockResponse().setResponseCode(200).setBody(patched.getValue("response").toString())) + val connected = CompletableDeferred() + val host = AidenBotChatActivityHost(client = { client }, waitForConnection = { connected.await() }, launch = { }, cache = { }) + + // Closed with nothing saving: let go at once. + val idle = host.controller(bot) { } + assertEquals(1, host.heldCount) + host.release(bot.id, idle) + assertEquals(0, host.heldCount) + + // Closed mid-save: kept until the save reports, which still reaches the screen behind. + val reported = mutableListOf() + val saving = host.controller(bot.copy(revision = "bot_revision_8")) { reported += it.revision } + val pick = async(Dispatchers.Unconfined) { saving.pick(detailed) } + host.release(bot.id, saving) + assertEquals("still saving", 1, host.heldCount) + connected.complete(true) + pick.await() + assertEquals(listOf("bot_revision_9"), reported) + assertEquals(0, host.heldCount) + } finally { + server.shutdown() + } + } + + // One writer per Bot: a sheet reopened while a pick is still saving gets the same controller. + @Test + fun aSheetReopenedMidSaveReusesTheControllerAndTheNewestSheetHearsTheResult() = runBlocking { + val patched = fixture.getValue("botActivity").jsonObject.getValue("identityPatch").jsonObject + val server = MockWebServer() + server.start() + try { + val client = AidenRemoteClient( + endpoint = server.url("/api/aiden/v1").toString().trimEnd('/'), + credential = "test_credential_123", + customOkHttpClient = OkHttpClient() + ) + server.enqueue(MockResponse().setResponseCode(200).setBody(patched.getValue("response").toString())) + val connected = CompletableDeferred() + val host = AidenBotChatActivityHost(client = { client }, waitForConnection = { connected.await() }, launch = { }, cache = { }) + val oldSheet = mutableListOf() + val newSheet = mutableListOf() + val first = host.controller(bot.copy(revision = "bot_revision_8")) { oldSheet += it.revision } + val saving = async(Dispatchers.Unconfined) { first.pick(detailed) } + assertTrue(first.state.value.isSaving) + + val second = host.controller(bot.copy(revision = "stale")) { newSheet += it.revision } + assertSame("no second writer on a stale revision", first, second) + + connected.complete(true) + saving.await() + assertEquals(emptyList(), oldSheet) + assertEquals(listOf("bot_revision_9"), newSheet) + assertNotSame("once it has settled a fresh sheet gets a fresh controller", first, host.controller(bot) { }) + } finally { + server.shutdown() + } + } + + // Advanced offers the page only to a phone that can use it. + @Test + fun theRowNeedsTheMacsFeatureTheGrantAndABot() { + fun mac(features: List, capabilities: List) = + AidenServer(instanceId = "inst", name = "Mac", capabilities = capabilities, features = features) + val feature = listOf(AidenRemoteProtocol.BOT_ACTIVITY_FEATURE) + val grant = listOf(AidenRemoteCapability.CHAT_READ, AidenRemoteCapability.BOT_ACTIVITY) + assertTrue(aidenBotShowsChatActivity(mac(feature, grant), hasBaseline = true)) + assertFalse("no Bot detail on screen yet", aidenBotShowsChatActivity(mac(feature, grant), hasBaseline = false)) + assertFalse("an older Mac", aidenBotShowsChatActivity(mac(emptyList(), grant), hasBaseline = true)) + assertFalse("no grant", aidenBotShowsChatActivity(mac(feature, listOf(AidenRemoteCapability.CHAT_READ)), hasBaseline = true)) + assertFalse("no Mac", aidenBotShowsChatActivity(null, hasBaseline = true)) + } + + // A cold offline start has no /server answer yet: the persisted grant stands in until it comes. + @Test + fun theRowSurvivesAColdOfflineStartOnThePersistedGrant() { + fun mac(features: List, capabilities: List) = + AidenServer(instanceId = "inst", name = "Mac", capabilities = capabilities, features = features) + assertTrue(aidenBotShowsChatActivity(null, hasBaseline = true, cachedSupport = true)) + assertFalse("never granted", aidenBotShowsChatActivity(null, hasBaseline = true, cachedSupport = false)) + assertFalse("still needs a Bot on screen", aidenBotShowsChatActivity(null, hasBaseline = false, cachedSupport = true)) + assertFalse( + "once the Mac answers it decides, whatever was cached", + aidenBotShowsChatActivity(mac(emptyList(), listOf(AidenRemoteCapability.CHAT_READ)), hasBaseline = true, cachedSupport = true) + ) + assertTrue(aidenBotActivitySupported(null, cachedSupport = true)) + assertFalse(aidenBotActivitySupported(null, cachedSupport = false)) + } + + // Previews: what each level shows on the Mac. + private val levels = AidenBotChatActivityLevel.entries + private val all = AidenBotChatActivityPreview.FRAME_MILLIS.indices + + @Test + fun quietShowsOnlyTheLineAndNeverStepsOrARecap() { + for (index in all) { + val frame = AidenBotChatActivityPreview.frame(index, AidenBotChatActivityLevel.QUIET) + assertEquals(emptyList(), frame.steps) + assertNull(frame.running) + assertFalse(frame.recapShown) + } + } + + @Test + fun summaryAddsARecapAboveTheReplyOnly() { + val reply = AidenBotChatActivityPreview.REPLY_FRAME + for (index in all) { + val frame = AidenBotChatActivityPreview.frame(index, AidenBotChatActivityLevel.SUMMARY) + assertEquals("the recap folds the steps away", emptyList(), frame.steps) + assertNull(frame.running) + assertEquals("the recap comes with the reply", index == reply, frame.recapShown) + assertFalse("it starts folded", frame.recapOpen) + } + } + + @Test + fun detailedListsFinishedStepsAndShowsTheRunningOne() { + val preview = AidenBotChatActivityPreview + val thinking = preview.frame(0, AidenBotChatActivityLevel.DETAILED) + assertNull("nothing has started yet", thinking.running) + assertEquals(emptyList(), thinking.steps) + + for (index in 1 until preview.REPLY_FRAME) { + val frame = preview.frame(index, AidenBotChatActivityLevel.DETAILED) + val running = frame.running + assertTrue("frame $index shows its running step", running != null) + assertEquals("one more step has finished each frame", index - 1, frame.steps.size) + assertFalse("a running step is not also listed as finished", frame.steps.contains(running)) + assertFalse(frame.recapShown) + } + + val reply = preview.frame(preview.REPLY_FRAME, AidenBotChatActivityLevel.DETAILED) + assertNull("the reply has landed", reply.phase) + assertNull(reply.running) + assertEquals(3, reply.steps.size) + assertTrue(reply.recapShown) + assertTrue("Detailed's recap is open", reply.recapOpen) + } + + @Test + fun theLineFollowsTheTurnAtEveryLevelAndEndsOnTheReply() { + for (level in levels) { + val phases = all.map { AidenBotChatActivityPreview.frame(it, level).phase } + assertEquals("the line runs while it works and is gone once the reply lands", listOf(true, true, true, true, false), phases.map { it != null }) + assertEquals("the line tells the same story at every level", all.map { AidenBotChatActivityPreview.frame(it, AidenBotChatActivityLevel.QUIET).phase }, phases) + } + } + + @Test + fun theTurnLoopsOnTheSharedClock() { + val preview = AidenBotChatActivityPreview + val total = preview.FRAME_MILLIS.sum() + var start = 0L + preview.FRAME_MILLIS.forEachIndexed { index, duration -> + assertEquals("frame $index begins on time", index, preview.frameIndex(start)) + assertEquals("frame $index lasts its whole time", index, preview.frameIndex(start + duration - 1)) + start += duration + } + assertEquals("the turn loops", 0, preview.frameIndex(total)) + assertEquals(preview.REPLY_FRAME, preview.frameIndex(total - 1)) + assertEquals("a clock behind zero still lands on a frame", preview.REPLY_FRAME, preview.frameIndex(-1)) + assertEquals("every loop is the same", preview.frameIndex(1_500), preview.frameIndex(1_500 + 3 * total)) + } + + @Test + fun reduceMotionHoldsAStillFrameThatShowsWhatTheLevelAdds() { + val preview = AidenBotChatActivityPreview + val stills = levels.associateWith { preview.stillFrame(it) } + val quiet = preview.frame(stills.getValue(AidenBotChatActivityLevel.QUIET), AidenBotChatActivityLevel.QUIET) + assertTrue("Quiet holds the line mid-turn", quiet.phase != null) + + val summary = preview.frame(stills.getValue(AidenBotChatActivityLevel.SUMMARY), AidenBotChatActivityLevel.SUMMARY) + assertNull("Summary holds the reply", summary.phase) + assertTrue("with its recap", summary.recapShown) + + val detailed = preview.frame(stills.getValue(AidenBotChatActivityLevel.DETAILED), AidenBotChatActivityLevel.DETAILED) + assertTrue("Detailed holds the line mid-turn", detailed.phase != null) + assertTrue("with a finished step and the running one", detailed.steps.isNotEmpty() && detailed.running != null) + + assertEquals("the stills tell the three levels apart", 3, setOf(quiet, summary, detailed).size) + } + + @Test + fun aPreviewMovesOnlyWhileInFrontOnScreenAndAllowedToMove() { + val preview = AidenBotChatActivityPreview + assertTrue(preview.plays(reduceMotion = false, resumed = true, onScreen = true)) + assertFalse("Reduce Motion", preview.plays(reduceMotion = true, resumed = true, onScreen = true)) + assertFalse("app in the background", preview.plays(reduceMotion = false, resumed = false, onScreen = true)) + assertFalse("scrolled out of view", preview.plays(reduceMotion = false, resumed = true, onScreen = false)) + } + + @Test + fun whatAPreviewShowsFollowsTheClockOnlyWhilePlaying() { + val preview = AidenBotChatActivityPreview + val level = AidenBotChatActivityLevel.DETAILED + val clockAtThirdFrame = preview.FRAME_MILLIS[0] + preview.FRAME_MILLIS[1] + 1 + assertEquals(2, preview.shownFrame(level, reduceMotion = false, playing = true, current = 0, nowMs = clockAtThirdFrame)) + assertEquals("paused keeps the frame it had", 4, preview.shownFrame(level, reduceMotion = false, playing = false, current = 4, nowMs = clockAtThirdFrame)) + for (level in levels) { + assertEquals( + "Reduce Motion ignores the clock", + preview.stillFrame(level), + preview.shownFrame(level, reduceMotion = true, playing = false, current = 0, nowMs = clockAtThirdFrame + preview.FRAME_MILLIS[2] + 5) + ) + } + } +} diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatPresentationTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatPresentationTest.kt new file mode 100644 index 000000000..5e0b0aca8 --- /dev/null +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatPresentationTest.kt @@ -0,0 +1,129 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import sbtbiswas.AidenOnTheGo.config.AidenPalette +import sbtbiswas.AidenOnTheGo.config.AidenThemeCatalog +import sbtbiswas.AidenOnTheGo.models.AidenStreamInputMode +import sbtbiswas.AidenOnTheGo.ui.theme.aidenColorScheme +import sbtbiswas.AidenOnTheGo.ui.theme.aidenFrameDue + +/** How the Bot chat draws what isn't in the Mac's transcript yet, and how a working Bot's face says so. */ +class AidenBotChatPresentationTest { + @Test + fun onlyMessagesStillWaitingForTheReplyAreFadedAndEachSaysWhy() { + assertEquals(AidenBotPendingBubble(true, AidenBotPendingCaption.SENDING), aidenBotOutgoingBubble(AidenBotOutgoingLook.Sending)) + assertEquals( + AidenBotPendingBubble(true, AidenBotPendingCaption.QUEUED), + aidenBotOutgoingBubble(AidenBotOutgoingLook.Queued(AidenStreamInputMode.QUEUE)) + ) + // A steer has joined the reply: it reads like any sent message, with no caption. + assertEquals( + AidenBotPendingBubble(false, null), + aidenBotOutgoingBubble(AidenBotOutgoingLook.Queued(AidenStreamInputMode.STEER)) + ) + assertEquals(AidenBotPendingBubble(false, null), aidenBotOutgoingBubble(AidenBotOutgoingLook.Sent)) + assertEquals(AidenBotPendingBubble(false, AidenBotPendingCaption.FAILED), aidenBotOutgoingBubble(AidenBotOutgoingLook.Failed)) + } + + @Test + fun aWaitingBubbleLooksDifferentFromASentOneAndKeepsItsWordsReadableInEveryTheme() { + AidenThemeCatalog.palettes.forEach { (preset, palettes) -> + palettes.forEachIndexed { index, palette -> + val scheme = aidenColorScheme(palette, isDark = index == 1) + val waiting = aidenBotUserBubbleColors(scheme, faded = true) + val sent = aidenBotUserBubbleColors(scheme, faded = false) + val name = "$preset ${if (index == 1) "dark" else "light"}" + assertNotEquals("$name: a waiting bubble must not look sent", sent.container, waiting.container) + // Opaque, so the contrast holds whatever the bubble sits on. + assertEquals("$name: never dimmed with alpha", 1f, waiting.container.alpha) + assertEquals(1f, waiting.content.alpha) + val ratio = AidenPalette.contrastRatio(waiting.content, waiting.container) + assertTrue("$name: waiting text contrast $ratio is below 4.5:1", ratio >= 4.5f) + } + } + } + + @Test + fun captionsLinksAndErrorTextReadOnEverySurfaceTheyUseInEveryTheme() { + val failures = mutableListOf() + AidenThemeCatalog.palettes.forEach { (preset, palettes) -> + palettes.forEachIndexed { index, palette -> + val scheme = aidenColorScheme(palette, isDark = index == 1) + val text = aidenBotChatTextColors(scheme) + val name = "$preset ${if (index == 1) "dark" else "light"}" + // Where each is drawn: the chat and screens (the page), sheets, dialogs and menus + // (raised), and, for an error, its own tint. + val surfaces = mapOf("page" to scheme.background, "sheet" to palette.raised) + val inks = mapOf("caption" to text.caption, "Try again" to text.link, "error" to text.error) + for ((surface, color) in surfaces) { + for ((role, ink) in inks) { + val ratio = AidenPalette.contrastRatio(ink, color) + if (ratio < 4.5f) failures += "$name: $role on $surface is $ratio" + } + } + for ((surface, color) in mapOf("card" to scheme.surfaceContainer, "error tint" to scheme.errorContainer)) { + val ratio = AidenPalette.contrastRatio(text.error, color) + if (ratio < 4.5f) failures += "$name: error on $surface is $ratio" + } + // Still an error's colour, not the plain text one. + assertNotEquals("$name: error text keeps its colour", scheme.onSurface, text.error) + assertEquals(1f, text.error.alpha) + } + } + assertTrue("below 4.5:1:\n" + failures.joinToString("\n"), failures.isEmpty()) + } + + @Test + fun theChatFollowsNewRowsOnlyAtItsEndOrAfterTheirOwnSend() { + assertTrue("at the end", aidenBotFollowsTranscriptEnd(lastVisibleIndex = 9, totalCount = 10, justSent = false)) + assertTrue("a row or two short of it", aidenBotFollowsTranscriptEnd(lastVisibleIndex = 7, totalCount = 10, justSent = false)) + assertFalse("reading back", aidenBotFollowsTranscriptEnd(lastVisibleIndex = 3, totalCount = 10, justSent = false)) + assertTrue("their own message always shows", aidenBotFollowsTranscriptEnd(lastVisibleIndex = 3, totalCount = 10, justSent = true)) + } + + @Test + fun framesAreCappedAtAboutThirtyASecond() { + // A 120 Hz display offers a frame every ~8 ms: only one in four or so is drawn. + var shown = 0L + var drawn = 0 + for (elapsed in generateSequence(0L) { it + 8 }.takeWhile { it <= 1_000 }) { + if (aidenFrameDue(shown, elapsed)) { + shown = elapsed + drawn += 1 + } + } + assertTrue("$drawn frames in a second", drawn in 25..31) + assertTrue("a restarted clock draws at once", aidenFrameDue(500, 0)) + } + + @Test + fun theBusyModeKeepsThePersonsPickWhileTheBotWorksAndIsQueueAgainAfter() { + assertEquals(AidenStreamInputMode.STEER, aidenBotBusyMode(AidenStreamInputMode.STEER, isRunning = true)) + assertEquals(AidenStreamInputMode.QUEUE, aidenBotBusyMode(AidenStreamInputMode.STEER, isRunning = false)) + assertEquals(AidenStreamInputMode.QUEUE, aidenBotBusyMode(AidenStreamInputMode.QUEUE, isRunning = false)) + } + + @Test + fun aWorkingBotAlwaysShowsItAndOnlyMovesWhenMotionIsAllowedAndTheAppIsInFront() { + fun motion(photo: Boolean = false, reduce: Boolean = false, saver: Boolean = false, resumed: Boolean = true, working: Boolean = true) = + aidenBotFaceMotion(working = working, photo = photo, reduceMotion = reduce, powerSave = saver, resumed = resumed) + + // A canonical face glances; nothing else is needed. + assertEquals(AidenBotFaceMotion(glances = true, badge = false, badgeAnimates = false), motion()) + // R19: a face held still by Reduce Motion or battery saver wears the still glance badge. + assertEquals(AidenBotFaceMotion(glances = false, badge = true, badgeAnimates = false), motion(reduce = true)) + assertEquals(AidenBotFaceMotion(glances = false, badge = true, badgeAnimates = false), motion(saver = true)) + // A photo can't glance: its badge moves, unless motion is off. + assertEquals(AidenBotFaceMotion(glances = false, badge = true, badgeAnimates = true), motion(photo = true)) + assertEquals(AidenBotFaceMotion(glances = false, badge = true, badgeAnimates = false), motion(photo = true, reduce = true)) + // In the background nothing moves. + assertFalse(motion(resumed = false).glances) + assertFalse(motion(photo = true, resumed = false).badgeAnimates) + // An idle Bot shows nothing. + assertEquals(AidenBotFaceMotion(glances = false, badge = false, badgeAnimates = false), motion(working = false, reduce = true)) + } +} diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryTest.kt index 8c480080d..a185d3dc6 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryTest.kt @@ -1,5 +1,10 @@ package sbtbiswas.AidenOnTheGo.features.bots +import kotlinx.coroutines.async +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject @@ -17,6 +22,7 @@ import java.util.Locale import java.util.UUID /** Profile → Memory, driven through [AidenBotMemoryController] against the shared fixture views. */ +@OptIn(ExperimentalCoroutinesApi::class) class AidenBotMemoryTest { private val json = AidenBotWireJson.json private val fixture: JsonObject by lazy { @@ -44,14 +50,21 @@ class AidenBotMemoryTest { val failures = ArrayDeque() /** The view the next successful edit answers with; by default the edit is applied here. */ var nextView: AidenBotMemory? = null + /** Holds the next edits until completed, so a test can look at the page meanwhile. */ + var gate: kotlinx.coroutines.CompletableDeferred? = null + + /** Failures the next reads throw, in order. */ + val readFailures = ArrayDeque() override suspend fun memory(botId: String): AidenBotMemory { reads += 1 + readFailures.removeFirstOrNull()?.let { throw it } return view } override suspend fun edit(botId: String, edit: AidenBotMemoryEdit, key: UUID): AidenBotMemory { calls += Call(edit, key) + gate?.await() failures.removeFirstOrNull()?.let { throw it } view = nextView ?: apply(view, edit) nextView = null @@ -261,4 +274,82 @@ class AidenBotMemoryTest { assertEquals(AidenBotMemoryError.FAILED, aidenBotMemoryError(refusal(422, AidenRemoteErrorCode.INVALID_REQUEST))) assertEquals(AidenBotMemoryError.FAILED, aidenBotMemoryError(IOException("offline"))) } + + // --- R15: memory edits apply at once and go back on failure --- + + @Test + fun anEditShowsItsNewWordsAtOnceAndAFailurePutsThemBackWithTheDialogOpen() = runTest { + val mac = FakeMac(view).apply { gate = CompletableDeferred() } + val controller = loaded(mac) + val entry = view.user.entries.first() + controller.beginEdit(AidenBotMemoryTarget.USER, entry) + controller.updateDraft("Prefers short answers.") + mac.failures += refusal(422, AidenRemoteErrorCode.MEMORY_BLOCKED) + val saving = async { controller.saveEdit() } + runCurrent() + assertNull("the dialog closed at once", controller.state.value.editing) + assertEquals("Prefers short answers.", controller.state.value.view?.user?.entries?.first()?.text) + mac.gate!!.complete(Unit) + assertFalse(saving.await()) + assertEquals("back as it was", entry.text, controller.state.value.view?.user?.entries?.first()?.text) + val reopened = controller.state.value.editing!! + assertEquals("on the person's words", "Prefers short answers.", reopened.text) + assertEquals(AidenBotMemoryError.BLOCKED, reopened.error) + } + + @Test + fun aDeleteAndAnEraseApplyAtOnceAndComeBackOnFailure() = runTest { + val mac = FakeMac(view).apply { gate = CompletableDeferred() } + val controller = loaded(mac) + val gone = view.user.entries.last() + mac.failures += IOException("offline") + val deleting = async { controller.delete(AidenBotMemoryTarget.USER, gone.id) } + runCurrent() + assertEquals("the row went at once", listOf(view.user.entries.first()), controller.state.value.view?.user?.entries) + mac.gate!!.complete(Unit) + assertFalse(deleting.await()) + assertEquals(view.user.entries, controller.state.value.view?.user?.entries) + + mac.gate = CompletableDeferred() + mac.failures += IOException("offline") + val erasing = async { controller.erase() } + runCurrent() + assertEquals(AidenBotMemorySummary.Empty, AidenBotMemorySummary.of(controller.state.value.view!!)) + mac.gate!!.complete(Unit) + assertFalse(erasing.await()) + assertEquals(AidenBotMemorySummary.Things(3), AidenBotMemorySummary.of(controller.state.value.view!!)) + assertTrue(controller.state.value.eraseFailed) + } + + @Test + fun aViewTheMacSentMeanwhileIsNotRolledBack() = runTest { + val mac = FakeMac(view).apply { gate = CompletableDeferred() } + val controller = loaded(mac) + mac.failures += IOException("offline") + val deleting = async { controller.delete(AidenBotMemoryTarget.USER, view.user.entries.last().id) } + runCurrent() + // The page reloads while the delete waits: the Mac's newer view stands, even when the + // reload after the failure can't reach the Mac. + mac.view = edited + controller.load() + mac.readFailures += IOException("offline") + mac.gate!!.complete(Unit) + assertFalse(deleting.await()) + assertEquals(edited, controller.state.value.view) + } + + @Test + fun thePageWaitsForTheMacOnSkeletonsAndThenSaysItCouldNotLoad() = runTest { + val connection = CompletableDeferred() + val controller = AidenBotMemoryController(view.botId, FakeMac(view), waitForConnection = { connection.await() }) + val loading = async { controller.load() } + runCurrent() + assertTrue("skeletons while the Mac connects", controller.state.value.isLoading) + advanceTimeBy(AIDEN_BOT_SAVE_HOLD_MILLIS + 1) + assertFalse(loading.await()) + assertTrue(controller.state.value.loadFailed) + + val connected = AidenBotMemoryController(view.botId, FakeMac(view), waitForConnection = { true }) + assertTrue(connected.load()) + } } diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt new file mode 100644 index 000000000..98e77e00f --- /dev/null +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt @@ -0,0 +1,908 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import sbtbiswas.AidenOnTheGo.auth.InMemoryAidenSecureStore +import sbtbiswas.AidenOnTheGo.features.remote.AidenPairingIdentity +import sbtbiswas.AidenOnTheGo.features.remote.AidenRemoteCoordinator +import sbtbiswas.AidenOnTheGo.models.* +import sbtbiswas.AidenOnTheGo.networking.AidenRemoteClient +import sbtbiswas.AidenOnTheGo.persistence.AidenBotCache +import sbtbiswas.AidenOnTheGo.persistence.AidenInstallationStore +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteCapability +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteClientException +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteErrorCode +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteErrorEnvelope +import java.io.IOException +import java.time.Instant +import java.util.UUID + +/** R15: Bot edits on the phone apply at once, wait for the connection on their own, and roll back on failure. */ +@OptIn(ExperimentalCoroutinesApi::class) +class AidenBotOptimisticSavesTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private val json = AidenBotWireJson.json + private val fixture: JsonObject by lazy { + val text = requireNotNull(javaClass.classLoader?.getResourceAsStream("contract.json")) + .bufferedReader().use { it.readText() } + json.parseToJsonElement(text).jsonObject + } + private val bot by lazy { json.decodeFromJsonElement(AidenBotDetail.serializer(), fixture.getValue("botDetail")) } + + private fun conflict() = AidenRemoteClientException.Server(409, AidenRemoteErrorEnvelope.Body(AidenRemoteErrorCode.REVISION_CONFLICT, "Stale.", "req_1", false)) + private fun refused() = AidenRemoteClientException.Server(422, AidenRemoteErrorEnvelope.Body(AidenRemoteErrorCode.INVALID_REQUEST, "No.", "req_1", false)) + + /** A Mac holding one Bot: each save applies the patch on the revision it was sent on, unless told to fail or hang. */ + private inner class Mac { + var held = bot + val sends = mutableListOf>() + val failures = ArrayDeque() + var gate: CompletableDeferred? = null + var hang = false + /** The save landed on the Mac, but its answer never came. */ + var landsThenHangs = false + var refreshes = 0 + + suspend fun save(saved: AidenBotDetail, patch: AidenBotIdentityPatch): AidenBotDetail { + sends += saved.revision to patch + gate?.await() + failures.removeFirstOrNull()?.let { throw it } + if (landsThenHangs) { + held = held.applying(patch).copy(revision = "${held.revision}+") + awaitCancellation() + } + if (hang) awaitCancellation() + if (saved.revision != held.revision) throw conflict() + held = held.applying(patch).copy(revision = "${held.revision}+", updatedAt = held.updatedAt.plusSeconds(1)) + return held + } + + suspend fun refresh(): AidenBotDetail { + refreshes += 1 + return held + } + } + + private fun TestScope.editor( + mac: Mac, + connected: suspend () -> Boolean = { true }, + shown: MutableList = mutableListOf(), + saved: MutableList = mutableListOf() + ) = AidenBotIdentityEditor( + bot, + save = mac::save, + refresh = mac::refresh, + onSaved = { saved += it }, + onShown = { shown += it }, + waitForConnection = connected + ) + + @Test + fun anEditShowsAtOnceAndTheMacsBotReplacesIt() = runTest { + val mac = Mac().apply { gate = CompletableDeferred() } + val shown = mutableListOf() + val editor = editor(mac, shown = shown) + val editing = async { editor.edit(AidenBotIdentityPatch(name = "Chef")) } + runCurrent() + assertEquals("shown before the Mac answers", "Chef", editor.state.value.bot.name) + assertEquals("every screen sees it through the cache", "Chef", shown.last().name) + assertTrue(editor.state.value.isSaving) + mac.gate!!.complete(Unit) + editing.await() + assertEquals("Chef", editor.state.value.bot.name) + assertEquals("${bot.revision}+", editor.state.value.bot.revision) + assertFalse(editor.state.value.isSaving) + assertNull(editor.state.value.failure) + } + + @Test + fun editsMadeWhileOneSavesGoOutTogetherNextOnTheNewestRevision() = runTest { + val mac = Mac().apply { gate = CompletableDeferred() } + val editor = editor(mac) + val first = async { editor.edit(AidenBotIdentityPatch(name = "Chef")) } + runCurrent() + // Picked while the first saves: they return at once and show at once. + editor.edit(AidenBotIdentityPatch(purpose = "Plans meals")) + editor.edit(AidenBotIdentityPatch(purpose = "Plans dinners")) + assertEquals("Plans dinners", editor.state.value.bot.purpose) + mac.gate!!.complete(Unit) + first.await() + assertEquals(2, mac.sends.size) + assertEquals("${bot.revision}+", mac.sends[1].first) + assertEquals(AidenBotIdentityPatch(purpose = "Plans dinners"), mac.sends[1].second) + assertEquals("Plans dinners", mac.held.purpose) + } + + @Test + fun aRefusedEditGoesBackAndSaysSoWhileALaterOneStays() = runTest { + val mac = Mac().apply { + gate = CompletableDeferred() + failures += refused() + } + val editor = editor(mac) + val first = async { editor.edit(AidenBotIdentityPatch(name = "Chef")) } + runCurrent() + editor.edit(AidenBotIdentityPatch(purpose = "Plans meals")) + mac.gate!!.complete(Unit) + first.await() + assertEquals("the refused name went back", bot.name, editor.state.value.bot.name) + assertEquals("the later edit still saved", "Plans meals", editor.state.value.bot.purpose) + assertEquals("Plans meals", mac.held.purpose) + assertEquals(AidenBotChatActivityFailure.GENERIC, editor.state.value.failure) + } + + @Test + fun anEditOfflineWaitsForTheMacThenGoesBackAsUnreachable() = runTest { + val mac = Mac() + val connection = CompletableDeferred() + val editor = editor(mac, connected = { connection.await() }) + val editing = async { editor.edit(AidenBotIdentityPatch(name = "Chef")) } + runCurrent() + assertEquals("shown while the Mac connects", "Chef", editor.state.value.bot.name) + assertTrue("nothing sent before the connection", mac.sends.isEmpty()) + advanceTimeBy(AIDEN_BOT_SAVE_HOLD_MILLIS + 1) + editing.await() + assertEquals(bot.name, editor.state.value.bot.name) + assertEquals(AidenBotChatActivityFailure.OFFLINE, editor.state.value.failure) + assertTrue(mac.sends.isEmpty()) + } + + @Test + fun aConnectionThatComesInTimeSendsTheEdit() = runTest { + val mac = Mac() + val connection = CompletableDeferred() + val editor = editor(mac, connected = { connection.await() }) + val editing = async { editor.edit(AidenBotIdentityPatch(name = "Chef")) } + advanceTimeBy(3_000) + connection.complete(true) + editing.await() + assertEquals("Chef", mac.held.name) + assertNull(editor.state.value.failure) + } + + @Test + fun anEditThatLandedButGotNoAnswerShowsAsSaved() = runTest { + val mac = Mac().apply { landsThenHangs = true } + val saved = mutableListOf() + val editor = editor(mac, saved = saved) + val editing = async { editor.edit(AidenBotIdentityPatch(name = "Chef")) } + advanceTimeBy(AIDEN_BOT_SAVE_LIMIT_MILLIS + 1) + editing.await() + assertEquals(1, mac.refreshes) + assertEquals("Chef", editor.state.value.bot.name) + assertNull("no error for a save that landed", editor.state.value.failure) + assertEquals("the screens behind hear the Mac's Bot", "Chef", saved.last().name) + } + + @Test + fun aStaleRevisionAdoptsTheMacsBotSoTheNextEditSaves() = runTest { + val mac = Mac() + mac.held = bot.copy(revision = "changed_on_mac", purpose = "From the Mac") + val editor = editor(mac) + editor.edit(AidenBotIdentityPatch(name = "Chef")) + assertEquals(AidenBotChatActivityFailure.CONFLICT, editor.state.value.failure) + assertEquals("what the Mac holds now", "From the Mac", editor.state.value.bot.purpose) + assertEquals(bot.name, editor.state.value.bot.name) + editor.edit(AidenBotIdentityPatch(name = "Chef")) + assertEquals("changed_on_mac", mac.sends.last().first) + assertEquals("Chef", editor.state.value.bot.name) + assertNull(editor.state.value.failure) + } + + @Test + fun aReadDoesNotOverwriteAnEditStillSavingAndNeverGoesBackInTime() = runTest { + val mac = Mac().apply { gate = CompletableDeferred() } + val editor = editor(mac) + val editing = async { editor.edit(AidenBotIdentityPatch(name = "Chef")) } + runCurrent() + editor.adopt(bot.copy(name = "From a read", updatedAt = bot.updatedAt.plusSeconds(60))) + assertEquals("Chef", editor.state.value.bot.name) + mac.gate!!.complete(Unit) + editing.await() + editor.adopt(bot.copy(name = "Older", updatedAt = bot.createdAt)) + assertEquals("Chef", editor.state.value.bot.name) + val newer = mac.held.copy(name = "Renamed on the Mac", updatedAt = mac.held.updatedAt.plusSeconds(60)) + editor.adopt(newer) + assertEquals("Renamed on the Mac", editor.state.value.bot.name) + } + + @Test + fun aRequestThatHasGoneOutFinishesWhenItsCallerLeaves() = runTest { + val mac = Mac().apply { gate = CompletableDeferred() } + val saved = mutableListOf() + val editor = editor(mac, saved = saved) + val job = launch { editor.edit(AidenBotIdentityPatch(name = "Chef")) } + runCurrent() + job.cancel() + mac.gate!!.complete(Unit) + runCurrent() + assertEquals("Chef", mac.held.name) + assertEquals("the screens behind still hear it", "Chef", saved.single().name) + } + + // Advanced's Save (model pick and access). + private val catalog by lazy { + json.decodeFromJsonElement(AidenBotCapabilityCatalog.serializer(), fixture.getValue("botCapabilityCatalog")) + } + + @Test + fun advancedSaveShowsTheNewModelAtOnceAndPutsTheBotBackWhenItCantReachTheMac() = runTest { + val start = bot.copy(sessionState = AidenBotSessionState.NEEDS_MODEL) + val draft = AidenBotAdvancedDraft.fromDetail(start, catalog)!!.let { it.copy(usesFullAccess = !it.usesFullAccess) } + val cache = mutableMapOf() + val disk = mutableListOf() + val shown = mutableListOf() + val settled = mutableListOf() + val failures = mutableListOf() + val host = AidenBotSaveHost( + client = { null }, + waitForConnection = { false }, + launch = { block -> launch { block() } }, + show = { cache[it.id] = it }, + cache = { cache[it.id] = it; disk += it }, + cached = { cache[it] } + ) + aidenBotSaveAdvancedOptimistically(host, start, draft, catalog, show = { shown += it }, settled = { settled += it }, failed = { failures += it }) + val optimistic = shown.single() + assertEquals(AidenBotModelSelection(draft.customAccess.providerID, draft.customAccess.modelID), optimistic.modelSelection) + assertEquals("a Bot that needed a model doesn't now", AidenBotSessionState.IDLE, optimistic.sessionState) + assertEquals("the cache shows it at once", optimistic, cache[bot.id]) + + runCurrent() + assertEquals(listOf(AidenBotChatActivityFailure.OFFLINE), failures) + assertEquals(start, cache[bot.id]) + assertEquals(listOf(start), settled) + assertTrue("nothing the Mac didn't confirm is kept", disk.isEmpty()) + } + + @Test + fun aFailedAdvancedSaveLeavesANewerBotInTheCache() = runTest { + val draft = AidenBotAdvancedDraft.fromDetail(bot, catalog)!!.let { it.copy(usesFullAccess = !it.usesFullAccess) } + val cache = mutableMapOf() + val gate = CompletableDeferred() + val host = AidenBotSaveHost( + client = { null }, + waitForConnection = { gate.await() }, + launch = { block -> launch { block() } }, + show = { cache[it.id] = it }, + cache = { cache[it.id] = it }, + cached = { cache[it] } + ) + aidenBotSaveAdvancedOptimistically(host, bot, draft, catalog, show = {}, settled = {}, failed = {}) + runCurrent() + val newer = bot.copy(name = "Saved elsewhere") + cache[bot.id] = newer + gate.complete(false) + runCurrent() + assertEquals("nothing newer is rolled back", newer, cache[bot.id]) + } + + @Test + fun aHeldRequestWaitsForTheConnectionAndIsBounded() = runTest { + val never = CompletableDeferred() + val unreachable = async { runCatching { aidenBotHeldRequest({ never.await() }) { "sent" } } } + advanceTimeBy(AIDEN_BOT_SAVE_HOLD_MILLIS + 1) + assertTrue(unreachable.await().exceptionOrNull() is AidenBotChatActivityUnreachable) + + val slow = async { runCatching { aidenBotHeldRequest({ true }) { awaitCancellation() } } } + advanceTimeBy(AIDEN_BOT_SAVE_LIMIT_MILLIS + 1) + val timeout = slow.await().exceptionOrNull() + assertTrue(timeout is AidenBotChatActivityTimeout) + assertTrue("a timeout may have reached the Mac", aidenBotChatActivityTransportFailure(timeout!!)) + } + + // Bot create stays idempotent when its request times out (R15 bounds it). + @Test + fun aCreateThatTimedOutReplaysItsKeyWhileTheMacKeepsIt() { + val keys = ArrayDeque(List(2) { UUID.randomUUID() }) + var now = 0L + val attempts = AidenBotCreateAttempts(nowMs = { now }, makeKey = { keys.removeFirst() }) + val draft = AidenBotCreateDraft(name = "Chef", help = "Plans meals") + val first = attempts.begin(draft, catalog)!! + attempts.failed(first, AidenBotChatActivityTimeout()) + now = AidenBotSessionStore.CREATE_REPLAY_MS - 1 + assertTrue(attempts.replays(draft)) + assertEquals("the Mac may have made it", first.key, attempts.begin(draft, catalog)!!.key) + + // The form left open past the window: the Mac may have let the key go, so it isn't replayed. + now = AidenBotSessionStore.CREATE_REPLAY_MS + assertFalse(attempts.replays(draft)) + assertNull(attempts.replay(draft)) + assertFalse("the person's own Create is a new request", attempts.begin(draft, catalog)!!.key == first.key) + } + + // --- What a relaunch reads: only what the Mac confirmed reaches the disk (as on iOS) --- + + private val pairing = AidenPairingExchange( + instanceId = "one", + deviceId = "device-one", + endpoint = "https://one.test/api/aiden/v1", + serverSpkiSha256 = "sha256/one", + credential = "secret-one", + capabilities = listOf(AidenRemoteCapability.CHAT_READ, AidenRemoteCapability.BOT_READ) + ) + + /** A paired phone whose Mac never answers: its scope runs nothing, so no request leaves. */ + private fun pairedPhone(): AidenRemoteCoordinator { + val installations = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + installations.addInstallation(pairing, null) + return AidenRemoteCoordinator( + installationStore = installations, + storageDir = tempFolder.root, + scope = CoroutineScope(StandardTestDispatcher() + SupervisorJob()) + ).also { it.refreshClient() } + } + + /** The Bot as the next launch reads it, after the process died. */ + private fun afterRelaunch(id: String): AidenBotDetail? = + AidenBotCache(tempFolder.root).apply { activate(pairing.instanceId, pairing.deviceId) }.getBotDetail(id) + + @Test + fun aProfileEditStillSavingShowsEverywhereButARelaunchReadsTheMacsBot() = runTest { + val phone = pairedPhone() + phone.pinnedBotCache()(bot) + val editor = phone.botIdentityEditor(bot) + val editing = launch { editor.edit(AidenBotIdentityPatch(name = "Chef")) } + runCurrent() + assertEquals("every screen shows the edit at once", "Chef", phone.botCache.getBotDetail(bot.id)?.name) + assertEquals("a process that dies mid-save never comes back showing it saved", bot, afterRelaunch(bot.id)) + + // The Mac never connects: the edit goes back, and the disk still holds the Mac's Bot. + advanceTimeBy(AIDEN_BOT_SAVE_HOLD_MILLIS + 1) + editing.join() + assertEquals(bot, phone.botCache.getBotDetail(bot.id)) + assertEquals(bot, afterRelaunch(bot.id)) + } + + @Test + fun anAdvancedSaveStillSavingShowsAtOnceButARelaunchReadsTheMacsBot() { + val phone = pairedPhone() + phone.pinnedBotCache()(bot) + val draft = AidenBotAdvancedDraft.fromDetail(bot, catalog)!!.let { it.copy(usesFullAccess = !it.usesFullAccess) } + aidenBotSaveAdvancedOptimistically(phone.botSaveHost(), bot, draft, catalog, show = {}, settled = {}, failed = {}) + assertEquals("shown at once", draft.optimistic(bot, catalog), phone.botCache.getBotDetail(bot.id)) + assertEquals("the disk holds only what the Mac confirmed", bot, afterRelaunch(bot.id)) + } + + @Test + fun onlyTheMacsAnswerToAProfileEditIsKept() = runBlocking { + val response = fixture.getValue("botActivity").jsonObject.getValue("identityPatch").jsonObject.getValue("response") + val answered = json.decodeFromJsonElement(AidenBotDetail.serializer(), response) + val server = MockWebServer() + server.start() + try { + server.enqueue(MockResponse().setResponseCode(200).setBody(response.toString())) + val client = AidenRemoteClient( + endpoint = server.url("/api/aiden/v1").toString().trimEnd('/'), + credential = "test_credential_123", + customOkHttpClient = OkHttpClient() + ) + val shown = mutableListOf() + val kept = mutableListOf() + val host = AidenBotSaveHost( + client = { client }, + waitForConnection = { true }, + launch = { }, + show = { shown += it }, + cache = { kept += it } + ) + aidenBotIdentityEditor(bot, host).edit(AidenBotIdentityPatch(name = "Chef")) + assertTrue("the edit showed while it saved", shown.any { it.name == "Chef" && it.revision == bot.revision }) + assertEquals("only the Mac's Bot is kept", listOf(answered), kept) + } finally { + server.shutdown() + } + } + + // --- New Bot (R15, as on iOS): Create closes at once; Bots home shows it on its way --- + + /** A Mac that makes one Bot per idempotency key, and can lose its answer or refuse. */ + private inner class CreatingMac { + val made = linkedMapOf() + val requests = mutableListOf>() + var gate: CompletableDeferred? = null + /** The next create lands but its answer never comes. */ + var losesAnswer = false + var refuses = false + var revoked = false + + suspend fun create(request: AidenBotCreateRequest, key: UUID): AidenBotDetail { + requests += request to key + gate?.await() + if (refuses) throw refused() + if (revoked) throw AidenRemoteClientException.UnexpectedStatus(401) + val created = made.getOrPut(key) { bot.copy(name = request.name, purpose = request.purpose, revision = "bot_made_${made.size + 1}") } + if (losesAnswer) { + losesAnswer = false + awaitCancellation() + } + return created + } + } + + private fun TestScope.createFlow( + mac: CreatingMac, + connected: suspend () -> Boolean = { true }, + kept: MutableList = mutableListOf() + ) = AidenBotCreateFlow(waitForConnection = connected, create = mac::create, cache = { kept += it }) + + @Test + fun aNewBotShowsAsOnItsWayAtOnceAndOpensOnceTheMacMadeIt() = runTest { + val mac = CreatingMac().apply { gate = CompletableDeferred() } + val kept = mutableListOf() + val flow = createFlow(mac, kept = kept) + val opened = mutableListOf() + val listening = launch { flow.created.collect { opened += it } } + runCurrent() + val draft = AidenBotCreateDraft(name = " Chef ", help = "Plans meals") + + val attempt = flow.start(draft, catalog)!! + assertEquals("the row shows before anything is sent", "Chef", flow.pending.value?.name) + assertNull("a second Create waits for this one", flow.start(AidenBotCreateDraft(name = "Other"), catalog)) + val sending = launch { flow.send(attempt) } + runCurrent() + assertEquals("Chef", flow.pending.value?.name) + + mac.gate!!.complete(Unit) + sending.join() + assertNull("the row gives way to the Bot", flow.pending.value) + assertNull(flow.failure.value) + assertEquals(listOf(mac.made.values.single()), kept) + assertEquals(listOf(mac.made.values.single()), opened) + listening.cancel() + } + + @Test + fun aFailedCreateLeavesTryAgainWithTheSameAnswersAndKeyAndNeverMakesTwoBots() = runTest { + val mac = CreatingMac().apply { losesAnswer = true } + val flow = createFlow(mac) + val draft = AidenBotCreateDraft(name = "Chef", help = "Plans meals", model = aidenBotRecommendedModel(catalog)) + + val first = flow.start(draft, catalog)!! + val sending = launch { flow.send(first) } + advanceTimeBy(AIDEN_BOT_SAVE_LIMIT_MILLIS + 1) + sending.join() + assertNull("the row goes", flow.pending.value) + val failure = flow.failure.value!! + assertEquals(draft, failure.draft) + assertEquals(sbtbiswas.AidenOnTheGo.R.string.bot_editor_create_unreachable, failure.message) + + // Try again reopens New Bot as it was. The Mac's catalog moved on meanwhile. + assertNull("this launch's failure reopens New Bot", flow.retry()) + assertNull(flow.failure.value) + val reopened = flow.reopening() + assertEquals(draft, reopened) + assertTrue("Create stays enabled for the same answers", flow.replays(reopened!!)) + val moved = catalog.copy(revision = "catalog_revision_moved") + val second = flow.start(reopened, moved)!! + assertNull("Create takes the reopened answers", flow.reopening()) + assertEquals("the same Idempotency-Key", first.key, second.key) + assertEquals("the frozen request, not one rebuilt on the new catalog", first.request, second.request) + flow.send(second) + + assertEquals("one Bot, however many tries", 1, mac.made.size) + assertEquals(listOf(first.key, first.key), mac.requests.map { it.second }) + assertNull(flow.failure.value) + // Made: the same answers again are another Bot, not a replay of this one. + assertFalse(flow.replays(draft)) + assertFalse(flow.start(draft, catalog)!!.key == first.key) + } + + @Test + fun aTryThatNeverReachedTheMacKeepsTheKeyOfOneThatMayHave() = runTest { + val mac = CreatingMac().apply { losesAnswer = true } + var reachable = true + val flow = createFlow(mac, connected = { reachable }) + val draft = AidenBotCreateDraft(name = "Chef") + val first = flow.start(draft, catalog)!! + val timedOut = launch { flow.send(first) } + advanceTimeBy(AIDEN_BOT_SAVE_LIMIT_MILLIS + 1) + timedOut.join() + + // Offline: nothing goes out, and the try that may have made the Bot stays the one to replay. + reachable = false + flow.send(flow.start(draft, catalog)!!) + assertEquals(1, mac.requests.size) + reachable = true + val third = flow.start(draft, catalog)!! + assertEquals(first.key, third.key) + flow.send(third) + assertEquals(1, mac.made.size) + } + + @Test + fun aRefusedCreateTriesAgainAsANewRequestAndAMadeBotIsNotReplayedLater() = runTest { + val mac = CreatingMac().apply { refuses = true } + val flow = createFlow(mac) + val draft = AidenBotCreateDraft(name = "Chef") + val refusedTry = flow.start(draft, catalog)!! + flow.send(refusedTry) + assertEquals(sbtbiswas.AidenOnTheGo.R.string.bot_editor_create_failed, flow.failure.value?.message) + assertFalse("a refusal is never replayed", flow.replays(draft)) + + mac.refuses = false + val made = flow.start(draft, catalog)!! + assertFalse(made.key == refusedTry.key) + flow.send(made) + // Another Bot with the same answers is another Bot. + val another = flow.start(draft, catalog)!! + assertFalse(another.key == made.key) + } + + // --- A New Bot cut off by a process death comes back (as iOS 9685204a9) --- + + private fun body(request: AidenBotCreateRequest) = json.encodeToString(AidenBotCreateRequest.serializer(), request) + + /** The pairing these launches' asks belong to. */ + private val paired = AidenPairingIdentity("one", "device-one", Instant.ofEpochMilli(1_000)) + + /** One launch of the app: a flow whose ask waits in a session store over [root], its disk work run on [io]. */ + private fun TestScope.launchWith( + root: java.io.File, + mac: CreatingMac, + io: java.util.concurrent.Executor = java.util.concurrent.Executor { it.run() }, + clock: () -> Long = { currentTime } + ): AidenBotCreateFlow { + val sessions = AidenBotSessionStore(root, io = io, nowMs = clock) + return AidenBotCreateFlow( + waitForConnection = { true }, + create = mac::create, + cache = {}, + store = AidenBotCreateStore( + load = { sessions.pendingCreate(paired) }, + save = { sessions.savePendingCreate(paired, it) }, + remove = { sessions.removePendingCreate(paired) } + ), + nowMs = clock + ) + } + + @Test + fun aCreateCutOffByAProcessDeathComesBackAndTryAgainReplaysTheSameKeyAndBody() = runTest { + val root = tempFolder.newFolder("outbox") + // The Mac makes the Bot, but the process dies before its answer comes. + val mac = CreatingMac().apply { losesAnswer = true } + val draft = AidenBotCreateDraft(name = "Chef", help = "Plans meals", model = aidenBotRecommendedModel(catalog)) + val before = launchWith(root, mac) + val first = before.start(draft, catalog)!! + val sending = launch { before.send(first) } + runCurrent() + sending.cancel() + + val after = launchWith(root, mac) + after.restore() + val failure = after.failure.value!! + assertEquals("the answers come back", draft, failure.draft) + assertEquals(sbtbiswas.AidenOnTheGo.R.string.bot_create_interrupted, failure.message) + assertTrue("it may have reached the Mac, so Try again sends it again", failure.replays) + + val again = after.retry()!! + assertEquals("the row shows again", "Chef", after.pending.value?.name) + assertEquals("the same Idempotency-Key", first.key, again.key) + assertEquals("the same body", body(first.request), body(again.request)) + after.send(again) + assertEquals(listOf(first.key, first.key), mac.requests.map { it.second }) + assertEquals(1, mac.requests.map { body(it.first) }.toSet().size) + assertEquals("one Bot, however many launches", 1, mac.made.size) + + // Made: nothing waits for the next launch. + val later = launchWith(root, mac) + later.restore() + assertNull(later.failure.value) + } + + /** The store's disk queue, run by hand: a write waits until the test lets it through. */ + private class HeldIo : java.util.concurrent.Executor { + private val queued = ArrayDeque() + override fun execute(command: Runnable) { + queued += command + } + + fun release() { + while (queued.isNotEmpty()) queued.removeFirst().run() + } + } + + /** The ask as the disk holds it now, read by a store of its own (a relaunch would read this). */ + private suspend fun TestScope.onDisk(root: java.io.File): AidenBotPendingCreate? = + AidenBotSessionStore(root, io = { it.run() }, nowMs = { currentTime }).pendingCreate(paired) + + @Test + fun aCreateGoesOutOnlyOnceItsRequestAndKeyAreOnDiskAndADeathAfterTheMacTookItReplaysThem() = runTest { + val root = tempFolder.newFolder("outbox") + val io = HeldIo() + val mac = CreatingMac().apply { losesAnswer = true } + val draft = AidenBotCreateDraft(name = "Chef", help = "Plans meals") + val before = launchWith(root, mac, io = io) + val first = before.start(draft, catalog)!! + assertEquals("the row shows at once", "Chef", before.pending.value?.name) + val sending = launch { before.send(first) } + runCurrent() + assertTrue("nothing goes out while the write waits", mac.requests.isEmpty()) + assertNull(onDisk(root)) + + io.release() + runCurrent() + assertEquals("out once it's written", listOf(first.key), mac.requests.map { it.second }) + val kept = onDisk(root)!! + assertEquals(first.key, kept.key) + assertEquals(body(first.request), body(kept.request!!)) + + // The Mac made the Bot; the process dies before its answer comes. + sending.cancel() + val io2 = HeldIo() + val after = launchWith(root, mac, io = io2) + val restoring = launch { after.restore() } + runCurrent() + io2.release() + restoring.join() + val again = after.retry()!! + assertEquals("the same Idempotency-Key", first.key, again.key) + assertEquals("the same body", body(first.request), body(again.request)) + // Try again waits for the disk too. + val resending = launch { after.send(again) } + runCurrent() + assertEquals(1, mac.requests.size) + io2.release() + resending.join() + assertEquals(listOf(first.key, first.key), mac.requests.map { it.second }) + assertEquals("one Bot, however many launches", 1, mac.made.size) + } + + @Test + fun aCreateThePhoneCouldntKeepIsNeverSentAndTryAgainReopensNewBot() = runTest { + // A file where the store's folder should be: every write fails. + val root = tempFolder.newFile("outbox") + val mac = CreatingMac() + val draft = AidenBotCreateDraft(name = "Chef") + val flow = launchWith(root, mac) + flow.send(flow.start(draft, catalog)!!) + assertTrue("nothing went out", mac.requests.isEmpty()) + assertNull(flow.pending.value) + val failure = flow.failure.value!! + assertEquals(sbtbiswas.AidenOnTheGo.R.string.bot_create_not_kept, failure.message) + assertEquals(draft, failure.draft) + assertNull(flow.retry()) + assertEquals("New Bot opens as it was", draft, flow.reopening()) + } + + @Test + fun aCutOffCreateReplaysOnlyWellInsideTheMacsDayAndThenTheStoreForgetsItsKey() = runTest { + val root = tempFolder.newFolder("outbox") + // The Mac made the Bot, but the process died before its answer came. + val mac = CreatingMac().apply { losesAnswer = true } + val draft = AidenBotCreateDraft(name = "Chef") + var now = 0L + val before = launchWith(root, mac) { now } + val first = before.start(draft, catalog)!! + launch { before.send(first) }.also { runCurrent() }.cancel() + + // An hour short of the Mac's day: Try again replays it. + now = AidenBotSessionStore.CREATE_REPLAY_MS - 1 + val inside = launchWith(root, mac) { now } + inside.restore() + assertTrue(inside.failure.value!!.replays) + + // From there on a Mac clock running ahead may have let the key go, and the same key would + // make a second Bot. + now = AidenBotSessionStore.CREATE_REPLAY_MS + val after = launchWith(root, mac) { now } + after.restore() + val failure = after.failure.value!! + assertFalse(failure.replays) + assertEquals("it may already be on the Mac", sbtbiswas.AidenOnTheGo.R.string.bot_create_may_exist, failure.message) + assertNull("Try again sends nothing", after.retry()) + assertEquals("New Bot opens as it was", draft, after.reopening()) + assertEquals(1, mac.requests.size) + + // The store forgot the key: a later launch, even with the clock turned back, never replays it. + now = 0 + val later = launchWith(root, mac) { now } + later.restore() + assertFalse(later.failure.value!!.replays) + assertEquals("the answers stay", draft, later.failure.value!!.draft) + assertNull(later.retry()) + assertEquals(1, mac.requests.size) + // Only the person's own Create there sends, as a new request. + assertFalse(later.start(draft, catalog)!!.key == first.key) + } + + @Test + fun aRestoredPromptLeftOpenPastTheReplayWindowReopensNewBotRatherThanReplaying() = runTest { + val root = tempFolder.newFolder("outbox") + val mac = CreatingMac().apply { losesAnswer = true } + val draft = AidenBotCreateDraft(name = "Chef") + var now = 0L + val before = launchWith(root, mac) { now } + val first = before.start(draft, catalog)!! + launch { before.send(first) }.also { runCurrent() }.cancel() + + now = AidenBotSessionStore.CREATE_REPLAY_MS - 1 + val after = launchWith(root, mac) { now } + after.restore() + assertTrue(after.failure.value!!.replays) + // The prompt stays open past the window before Try again. + now = AidenBotSessionStore.CREATE_REPLAY_MS + assertNull("nothing goes out", after.retry()) + assertNull(after.pending.value) + assertEquals("New Bot opens as it was", draft, after.reopening()) + assertFalse(after.replays(draft)) + assertEquals(1, mac.requests.size) + } + + @Test + fun aCutOffCreateTheMacsListShowsGoesQuietlyAndOKLetsItGo() = runTest { + val root = tempFolder.newFolder("outbox") + val mac = CreatingMac().apply { losesAnswer = true } + val before = launchWith(root, mac) + val first = before.start(AidenBotCreateDraft(name = "Chef", help = "Plans meals"), catalog)!! + launch { before.send(first) }.also { runCurrent() }.cancel() + + val after = launchWith(root, mac) + after.restore() + val failure = after.failure.value!! + val made = mac.made.values.single().summary().copy(createdAt = java.time.Instant.ofEpochMilli(first.createdAtMs)) + assertFalse("another Bot isn't it", aidenBotCreateWasTaken(failure, listOf(made.copy(name = "Other")))) + assertFalse("nor one made before it went out", aidenBotCreateWasTaken(failure, listOf(made.copy(createdAt = java.time.Instant.ofEpochMilli(first.createdAtMs - 3_600_000))))) + assertTrue(aidenBotCreateWasTaken(failure, listOf(made))) + after.settleTaken() + assertNull(after.failure.value) + assertFalse(after.replays(failure.draft)) + val later = launchWith(root, mac) + later.restore() + assertNull("nothing waits any more", later.failure.value) + + // OK lets a cut-off create go too. + val other = launchWith(root, mac) + other.start(AidenBotCreateDraft(name = "Scout"), catalog)!! + val next = launchWith(root, mac) + next.restore() + next.dismissFailure() + val last = launchWith(root, mac) + last.restore() + assertNull(last.failure.value) + } + + @Test + fun aCreateOnItsWayIsKeptPerPairingAndGoesWithAccessLossOrAnUnpair() = runTest { + // Access lost on the create itself: nothing of it is kept. + val root = tempFolder.newFolder("outbox") + val mac = CreatingMac().apply { revoked = true } + val flow = launchWith(root, mac) + flow.send(flow.start(AidenBotCreateDraft(name = "Chef"), catalog)!!) + val after = launchWith(root, mac) + after.restore() + assertNull(after.failure.value) + + // The coordinator keeps it per pairing, beside the chat outboxes, and an unpair purges it. + val phone = pairedPhone() + val first = phone.activePairing!! + phone.botCreateFlow().start(AidenBotCreateDraft(name = "Chef"), catalog)!! + assertEquals("Chef", phone.botSessionStore.pendingCreate(first)?.draft?.name) + assertNull("another pairing has none", phone.botSessionStore.pendingCreate(first.copy(deviceId = "device-other"))) + phone.removeInstallation(phone.installationStore.installations.value.single().id) + assertNull(phone.botSessionStore.pendingCreate(first)) + val late = phone.botSessionStore.savePendingCreate(first, AidenBotPendingCreate(AidenBotCreateDraft(name = "Late"), createdAtMs = 0)) + assertFalse(late.await()) + assertNull("never written again after the unpair", phone.botSessionStore.pendingCreate(first)) + + // Pairing that Mac again is another pairing: its New Bot is kept again. + phone.installationStore.addInstallation(pairing, null) + phone.refreshClient() + val again = phone.activePairing!! + assertFalse(again == first) + phone.botCreateFlow().start(AidenBotCreateDraft(name = "Scout"), catalog)!! + assertEquals("Scout", phone.botSessionStore.pendingCreate(again)?.draft?.name) + } + + @Test + fun aNewBotKeptForOnePairingNeverComesBackOnAnotherPairingOfTheSameMac() = runTest { + val root = tempFolder.newFolder("outbox") + fun store() = AidenBotSessionStore(root, io = { it.run() }) + // The pairing as it was made (to the nanosecond) and as a relaunch reads it back (to the millisecond). + val made = AidenPairingIdentity("one", "device-one", Instant.ofEpochSecond(1_000, 123_456_789)) + val relaunched = made.copy(pairedAt = Instant.ofEpochMilli(made.pairedAt.toEpochMilli())) + assertTrue(store().savePendingCreate(made, AidenBotPendingCreate(AidenBotCreateDraft(name = "Chef"), createdAtMs = 0)).await()) + assertEquals("a relaunch reads its own pairing's ask", "Chef", store().pendingCreate(relaunched)?.draft?.name) + + // Unpaired, but its purge never landed; pairing the same Mac and device again is another pairing. + val again = made.copy(pairedAt = made.pairedAt.plusSeconds(60)) + assertNull("the new pairing never sees the old one's ask", store().pendingCreate(again)) + assertNull("and it is gone", store().pendingCreate(made)) + } + + private val otherPairing = pairing.copy( + instanceId = "two", + deviceId = "device-two", + endpoint = "https://two.test/api/aiden/v1", + serverSpkiSha256 = "sha256/two", + credential = "secret-two" + ) + + @Test + fun aRestoreForOneMacThatLandsAfterASwitchToAnotherShowsNothingAndSendsNothing() = runTest { + val io = HeldIo() + val installations = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + val two = installations.addInstallation(otherPairing, null) + val one = installations.addInstallation(pairing, null) + val phone = AidenRemoteCoordinator( + installationStore = installations, + storageDir = tempFolder.root, + scope = CoroutineScope(StandardTestDispatcher() + SupervisorJob()), + botOutboxIo = io + ).also { it.refreshClient() } + // An earlier launch left Mac one's New Bot on its way, request and key alike. + val draft = AidenBotCreateDraft(name = "Chef", help = "Plans meals") + val left = AidenBotPendingCreate(draft, draft.request(catalog)!!, UUID.randomUUID(), System.currentTimeMillis()) + val seeded = phone.botSessionStore.savePendingCreate(phone.activePairing!!, left) + io.release() + assertTrue(seeded.await()) + + val onOne = phone.botCreateFlow() + val hostOne = phone.botSaveHost() + assertNotNull(hostOne.client()) + val restoring = launch { onOne.restore() } + runCurrent() + // Its read waits on the disk while Mac two comes into use; two's client follows a moment later. + installations.setActiveInstallation(two.id) + assertNull("Mac two's work never gets Mac one's client", phone.botSaveHost().client()) + assertNull(hostOne.client()) + phone.refreshClient() + io.release() + restoring.join() + + val onTwo = phone.botCreateFlow() + assertNotSame(onOne, onTwo) + assertNull("nothing of Mac one's shows", onOne.failure.value) + assertNull("Try again sends nothing", onOne.retry()) + assertNull(onOne.start(draft, catalog)) + assertNull(onOne.pending.value) + val restoringTwo = launch { onTwo.restore() } + runCurrent() + io.release() + restoringTwo.join() + assertNull("Mac two shows none of it", onTwo.failure.value) + assertNull(onTwo.reopening()) + + // Back on Mac one, its New Bot comes back, and Try again replays its own key. + installations.setActiveInstallation(one.id) + phone.refreshClient() + val back = phone.botCreateFlow() + assertSame(onOne, back) + val restoringOne = launch { back.restore() } + runCurrent() + io.release() + restoringOne.join() + assertEquals(draft, back.failure.value?.draft) + assertEquals(left.key, back.retry()?.key) + } +} diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt index 8041d6e52..8a3541ced 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt @@ -5,9 +5,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.advanceUntilIdle @@ -18,8 +20,12 @@ import kotlinx.serialization.json.jsonObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMarkKind import sbtbiswas.AidenOnTheGo.models.* import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteClientException import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteErrorCode @@ -29,6 +35,9 @@ import java.util.UUID @OptIn(ExperimentalCoroutinesApi::class) class AidenBotSessionControllerTest { + @get:Rule + val tempFolder = TemporaryFolder() + private val json = AidenBotWireJson.json private val fixture: JsonObject by lazy { val text = requireNotNull(javaClass.classLoader?.getResourceAsStream("contract.json")) @@ -48,20 +57,32 @@ class AidenBotSessionControllerTest { var resumeGate: CompletableDeferred? = null var failNextResume = false + /** Failures the next session reads throw, in order. */ + val sessionFailures = ArrayDeque() override suspend fun session(botId: String): AidenBotSession { sessionReads += 1 + sessionFailures.removeFirstOrNull()?.let { throw it } return session } - override fun events(botId: String): Flow = emptyFlow() + /** Feeds the next `events` calls return, in order; then a feed that ends at once. */ + val feeds = ArrayDeque>() + override fun events(botId: String): Flow = feeds.removeFirstOrNull() ?: emptyFlow() /** Failures the next sends throw, in order. */ val sendFailures = ArrayDeque() + /** The submission ids the next receipts carry, in order; then "1". */ + val receiptIds = ArrayDeque() + var sendGate: CompletableDeferred? = null + var receiptState = AidenBotSessionState.RUNNING - override suspend fun send(botId: String, text: String, key: UUID): AidenBotSessionSendResponse { + val sendModes = mutableListOf() + override suspend fun send(botId: String, text: String, key: UUID, whenBusy: AidenStreamInputMode?): AidenBotSessionSendResponse { sends += text to key + sendModes += whenBusy + sendGate?.await() sendFailures.removeFirstOrNull()?.let { throw it } - return AidenBotSessionSendResponse("1", false, AidenBotSessionState.RUNNING, false) + return AidenBotSessionSendResponse(receiptIds.removeFirstOrNull() ?: "1", false, receiptState, false) } override suspend fun resume(botId: String, key: UUID): AidenBotSessionStateView { @@ -74,11 +95,28 @@ class AidenBotSessionControllerTest { return AidenBotSessionStateView(AidenBotSessionState.RUNNING, false) } - override suspend fun dismiss(botId: String, key: UUID) = AidenBotSessionStateView(AidenBotSessionState.IDLE, false) - override suspend fun stop(botId: String, key: UUID) = AidenBotSessionStateView(AidenBotSessionState.IDLE, false) + var dismissGate: CompletableDeferred? = null + val dismissFailures = ArrayDeque() + override suspend fun dismiss(botId: String, key: UUID): AidenBotSessionStateView { + dismissGate?.await() + dismissFailures.removeFirstOrNull()?.let { throw it } + return AidenBotSessionStateView(AidenBotSessionState.IDLE, false) + } + + val stopKeys = mutableListOf() + var stopGate: CompletableDeferred? = null + val stopFailures = ArrayDeque() + var stopState = AidenBotSessionState.IDLE + override suspend fun stop(botId: String, key: UUID): AidenBotSessionStateView { + stopKeys += key + stopGate?.await() + stopFailures.removeFirstOrNull()?.let { throw it } + return AidenBotSessionStateView(stopState, false) + } val answerKeys = mutableListOf() var failNextAnswer = false + var answerGate: CompletableDeferred? = null override suspend fun answerQuestion( botId: String, @@ -87,6 +125,7 @@ class AidenBotSessionControllerTest { key: UUID ): AidenBotQuestionAnswerReceipt { answerKeys += key + answerGate?.await() if (failNextAnswer) { failNextAnswer = false throw IOException("offline") @@ -113,6 +152,7 @@ class AidenBotSessionControllerTest { val proposalCalls = mutableListOf() /** Failures the next proposal answers throw, in order. */ val proposalFailures = ArrayDeque() + var proposalGate: CompletableDeferred? = null override suspend fun respondToRoutineProposal( botId: String, @@ -121,6 +161,7 @@ class AidenBotSessionControllerTest { key: UUID ): AidenBotRoutineProposalRespondResult { proposalCalls += ProposalCall(proposalId, decision, key) + proposalGate?.await() proposalFailures.removeFirstOrNull()?.let { throw it } return when (decision) { AidenBotRoutineProposalDecision.ACCEPT -> @@ -366,7 +407,8 @@ class AidenBotSessionControllerTest { val gate = CompletableDeferred() transport.approvalGate = gate val first = async { controller.respondToApproval(AidenApprovalDecision.ALLOW) } - advanceUntilIdle() + // Held, not timed out: the clock stays short of the request limit. + runCurrent() assertTrue(controller.state.value.isRespondingToApproval) assertFalse(controller.respondToApproval(AidenApprovalDecision.DENY)) assertEquals(1, transport.approvalCalls.size) @@ -438,7 +480,7 @@ class AidenBotSessionControllerTest { transport.failNextResume = true val first = async { controller.resume() } val second = async { controller.resume() } - advanceUntilIdle() + runCurrent() assertEquals(1, transport.resumeKeys.size) assertFalse(second.await()) gate.complete(Unit) @@ -452,7 +494,8 @@ class AidenBotSessionControllerTest { assertEquals(transport.resumeKeys[0], transport.resumeKeys[1]) assertEquals(AidenBotSessionState.RUNNING, controller.state.value.state) - // A later, separate Resume is a new logical action with a new key. + // A later, separate Resume of another pause is a new logical action with a new key. + controller.refetch() controller.resume() assertNotEquals(transport.resumeKeys[1], transport.resumeKeys[2]) } @@ -478,13 +521,13 @@ class AidenBotSessionControllerTest { } @Test - fun sendKeepsItsKeyForARetryOfTheSameText() = runTest { + fun aLostSendKeepsItsKeyAndGoesOutAgainWhileConnected() = runTest { val idle = needsModelSession.copy(state = AidenBotSessionState.IDLE) val transport = object : AidenBotSessionTransport by FakeTransport(idle) { val keys = mutableListOf() var fail = true override suspend fun session(botId: String) = idle - override suspend fun send(botId: String, text: String, key: UUID): AidenBotSessionSendResponse { + override suspend fun send(botId: String, text: String, key: UUID, whenBusy: AidenStreamInputMode?): AidenBotSessionSendResponse { keys += key if (fail) { fail = false @@ -495,9 +538,14 @@ class AidenBotSessionControllerTest { } val controller = AidenBotSessionController(idle.botId, transport, backgroundScope) controller.refetch() - assertFalse(controller.send("Plan my week")) - assertTrue(controller.send("Plan my week")) - assertEquals(transport.keys[0], transport.keys[1]) + assertTrue("the bubble shows at once, even when the request is lost", controller.send("Plan my week")) + assertEquals(1, transport.keys.size) + assertEquals(AidenBotOutgoingLook.Sending, controller.state.value.look(controller.state.value.outgoing.single())) + advanceTimeBy(1_001) + runCurrent() + assertEquals(2, transport.keys.size) + assertEquals("the Mac may hold the first attempt: the same key", transport.keys[0], transport.keys[1]) + assertEquals(AidenBotOutgoingStatus.SENT, controller.state.value.outbox.single().status) } @Test @@ -760,31 +808,34 @@ class AidenBotSessionControllerTest { } @Test - fun anUnansweredRetryKeepsItsKeyAndAnotherFailedTurnGetsAFreshOne() = runTest { + fun aLostRetryKeepsItsKeyUntilTheMacIsBackAndARefusedOneGetsANewKey() = runTest { val first = failedTurns[0] val transport = FakeTransport(sessionEndingWith(first)) val controller = AidenBotSessionController(transport.session.botId, transport, backgroundScope) controller.refetch() + // Retry goes through the outbox (R18): it shows at once, and a lost request keeps its key. transport.sendFailures += IOException("offline") - assertFalse(controller.retry()) - assertEquals("Aiden couldn’t send that. Try again.", controller.state.value.actionError) assertTrue(controller.retry()) - assertEquals("the second tap replays the lost retry", transport.sends[0].second, transport.sends[1].second) - - // The Mac answered the next attempt with an error: that retry is finished. - transport.sendFailures += serverError(500, AidenRemoteErrorCode.INTERNAL_ERROR) + assertEquals(first.retryText, controller.state.value.outgoing.single().text) + assertNull("no second Retry while one is on its way", controller.state.value.retryableFailedTurn) assertFalse(controller.retry()) - assertTrue(controller.retry()) - assertNotEquals(transport.sends[2].second, transport.sends[3].second) + advanceTimeBy(1_001) + runCurrent() + assertEquals("the backoff replays the lost retry", transport.sends[0].second, transport.sends[1].second) - // A different failed turn, even with the same text, is a separate retry. - val second = first.copy(id = "entry_20:failed") - transport.session = sessionEndingWith(first, second) + // The Mac refused the next one outright: Try again is a new request. + transport.session = sessionEndingWith(first.copy(id = "entry_20:failed")) controller.refetch() + transport.sendFailures += serverError(400, AidenRemoteErrorCode.INVALID_REQUEST) + assertTrue(controller.retry()) + val failed = controller.state.value.outbox.single { it.retryOf == "entry_20:failed" } + assertEquals(AidenBotOutgoingLook.Failed, controller.state.value.look(failed)) assertTrue(controller.retry()) - assertEquals(5, transport.sends.size) - assertTrue(transport.sends.take(4).none { it.second == transport.sends[4].second }) + assertEquals(4, transport.sends.size) + assertNotEquals(transport.sends[2].second, transport.sends[3].second) + assertTrue("every failed turn has its own retry", transport.sends.take(2).none { it.second == transport.sends[3].second }) + assertEquals("one bubble for the retried message", 1, controller.state.value.outbox.count { it.retryOf == "entry_20:failed" }) } @Test @@ -863,4 +914,1378 @@ class AidenBotSessionControllerTest { assertFalse(controller.state.value.isInterrupted) assertEquals(AidenBotSessionState.RUNNING, controller.state.value.state) } + + // --- Revision 30: the status line, Reconnecting, Queued and Steer/Queue --- + + private val activitySession by lazy { + json.decodeFromJsonElement(AidenBotSession.serializer(), fixture.getValue("botActivity").jsonObject.getValue("session")) + } + private val started = 1_760_450_000_000L + + private fun ui( + session: AidenBotSession, + supported: Boolean = true, + wording: AidenBotChatActivityWording = AidenBotChatActivityWording.FOLLOWS + ) = AidenBotSessionUiState(session = session, isLoading = false, activitySupported = supported, wording = wording) + + // Test 1 (R7: the line hides only while reply text streams with no tool at work) + @Test + fun theLineShowsUntilTheReplyStreamsAndFollowsTheChosenWording() { + val line = aidenBotStatusLine(ui(activitySession), started + 1_000)!! + assertEquals(AidenBotActivityPhase.APP, line.display.phase) + assertEquals("Notion", line.display.app) + val thinking = AidenBotChatActivityWording.THINKING + assertEquals(AidenBotActivityPhase.THINKING, aidenBotStatusLine(ui(activitySession, wording = thinking), started)!!.display.phase) + val waiting = activitySession.copy(activity = AidenBotSessionActivity("waiting", null, started)) + assertEquals(AidenBotActivityPhase.WAITING, aidenBotStatusLine(ui(waiting, wording = thinking), started)!!.display.phase) + val writing = activitySession.copy(partial = "Here is", activity = AidenBotSessionActivity("writing", null, started)) + assertNull("the reply's words are the signal", aidenBotStatusLine(ui(writing), started)) + assertNull(aidenBotStatusLine(ui(activitySession.copy(partial = "Here is", activity = null)), started)) + // Narration text that outlasts the words into a tool call keeps the line, as on the Mac. + assertEquals( + AidenBotActivityPhase.APP, + aidenBotStatusLine(ui(activitySession.copy(partial = "Let me check Notion")), started)!!.display.phase + ) + assertEquals( + "whitespace is not a reply", + AidenBotActivityPhase.WRITING, + aidenBotStatusLine(ui(writing.copy(partial = " ")), started)!!.display.phase + ) + assertNull(aidenBotStatusLine(ui(activitySession.copy(state = AidenBotSessionState.IDLE)), started)) + } + + // Test 2 (Review Focus 3) + @Test + fun anUnknownPhaseReadsAsWorkingAndElapsedShowsAfterTwentySeconds() { + val browsing = activitySession.copy(activity = AidenBotSessionActivity("browsing", null, started)) + val early = aidenBotStatusLine(ui(browsing), started + 19_000)!! + assertEquals(AidenBotActivityPhase.WORKING, early.display.phase) + assertEquals(AidenActivityMarkKind.QUAD_SHUFFLE, early.display.mark) + assertNull(early.elapsed) + assertEquals("1m 12s", aidenBotStatusLine(ui(browsing), started + 72_000)!!.elapsed) + assertNull("a phone clock behind the Mac's shows no time", aidenBotStatusLine(ui(browsing), started - 60_000)!!.elapsed) + } + + // Test 3 + @Test + fun beforeTheFirstActivityFrameItSaysGettingReadyAndOlderMacsShowNothing() { + assertEquals( + AidenBotActivityPhase.PREPARING, + aidenBotStatusLine(ui(activitySession.copy(activity = null)), started)!!.display.phase + ) + assertNull(aidenBotStatusLine(ui(activitySession, supported = false), started)) + assertFalse(ui(activitySession, supported = false).offersBusyModes) + } + + // Test 4 (Review Focus 2) + @Test + fun aDroppedFeedReadsReconnectingUntilTheNextFrame() = runTest { + val transport = FakeTransport(activitySession) + transport.feeds += flow { throw IOException("Wi-Fi dropped") } + val reopened = activitySession.copy( + epoch = "epoch_fixture_03", + seq = 1, + activity = AidenBotSessionActivity("searching", null, started) + ) + transport.feeds += flow { + emit(AidenBotSessionEvent(reopened.botId, reopened.epoch, reopened.seq, AidenBotSessionEventPayload.Snapshot(reopened))) + awaitCancellation() + } + val controller = AidenBotSessionController( + activitySession.botId, transport, backgroundScope, activitySupported = true, reconnectDelayMillis = 1_000 + ) + controller.start() + runCurrent() + assertTrue(controller.state.value.streamDown) + assertEquals(AidenBotActivityPhase.RECONNECTING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + + advanceTimeBy(1_001) + runCurrent() + assertFalse(controller.state.value.streamDown) + assertEquals(AidenBotActivityPhase.SEARCHING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + controller.stopFollowing() + } + + // Test 5 + @Test + fun steerAndQueueGoOutOnlyWhileTheBotWorksOnAMacThatOffersThem() = runTest { + val transport = FakeTransport(activitySession) + val supported = AidenBotSessionController(activitySession.botId, transport, backgroundScope, activitySupported = true) + supported.refetch() + assertTrue(supported.state.value.offersBusyModes) + assertTrue(supported.send("Keep it vegetarian.", AidenStreamInputMode.STEER)) + val older = AidenBotSessionController(activitySession.botId, transport, backgroundScope) + older.refetch() + assertTrue(older.send("Also a list.", AidenStreamInputMode.QUEUE)) + // An idle Bot on a Mac that offers them takes no mode either. + transport.session = activitySession.copy(state = AidenBotSessionState.IDLE, activity = null, queued = emptyList()) + val idle = AidenBotSessionController(activitySession.botId, transport, backgroundScope, activitySupported = true) + idle.refetch() + assertTrue(idle.send("Thanks.", AidenStreamInputMode.STEER)) + assertEquals("an older Mac would refuse whenBusy", listOf(AidenStreamInputMode.STEER, null, null), transport.sendModes) + } + + // Test 6 + @Test + fun queuedAndActivityFramesFoldIntoTheSessionInSeqOrder() { + val waiting = listOf(AidenBotQueuedMessage("18", AidenStreamInputMode.QUEUE, "Also add a shopping list.")) + fun at(seq: Long, payload: AidenBotSessionEventPayload) = + AidenBotSessionEvent(activitySession.botId, activitySession.epoch, seq, payload) + val queued = aidenApplyBotSessionEvent(activitySession, at(activitySession.seq + 1, AidenBotSessionEventPayload.Queued(waiting))) + val afterQueued = (queued as AidenBotSessionEventOutcome.Applied).session + assertEquals(waiting, afterQueued.queued) + val cleared = aidenApplyBotSessionEvent(afterQueued, at(afterQueued.seq + 1, AidenBotSessionEventPayload.Activity(null))) + assertNull((cleared as AidenBotSessionEventOutcome.Applied).session.activity) + val gap = aidenApplyBotSessionEvent(afterQueued, at(afterQueued.seq + 2, AidenBotSessionEventPayload.Activity(null))) + assertEquals(AidenBotSessionEventOutcome.Refetch, gap) + } + + // --- R14 / R16 / R17: the optimistic outbox --- + + private suspend fun AidenBotSessionController.frame(payload: AidenBotSessionEventPayload): Boolean { + val s = state.value.session!! + return handle(AidenBotSessionEvent(s.botId, s.epoch, s.seq + 1, payload)) + } + + private fun userEntry(id: String, text: String, submissionId: String? = null) = + AidenBotSessionEntry.Message(id, AidenBotMessageRole.USER, text, submissionId = submissionId) + + /** The running fixture turn with nothing queued, so each test sets its own queue. */ + private val runningSession by lazy { activitySession.copy(queued = emptyList()) } + + @Test + fun identicalTextsSettleExactlyByTheirSubmissionIds() = runTest { + val transport = FakeTransport(runningSession) + transport.receiptIds += listOf("21", "22") + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, activitySupported = true) + controller.refetch() + assertTrue(controller.send("Yes.")) + assertTrue(controller.send("Yes.")) + assertEquals(2, controller.state.value.outgoing.size) + + // The second message's entry lands first: only that bubble gives way. + controller.frame(AidenBotSessionEventPayload.Entry(userEntry("entry_41", "Yes.", "22"))) + assertEquals(listOf("21"), controller.state.value.outgoing.map { it.submissionId }) + // An unrelated frame changes nothing. + controller.frame(AidenBotSessionEventPayload.Partial("Sure")) + assertEquals(1, controller.state.value.outgoing.size) + controller.frame(AidenBotSessionEventPayload.Entry(userEntry("entry_42", "Yes.", "21"))) + assertTrue(controller.state.value.outgoing.isEmpty()) + assertTrue(controller.state.value.outbox.isEmpty()) + } + + @Test + fun onAnOlderMacEachEntrySettlesOneBubbleOnceByItsText() = runTest { + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope) + controller.refetch() + assertTrue(controller.send("Yes.")) + assertTrue(controller.send("Yes.")) + controller.frame(AidenBotSessionEventPayload.Entry(userEntry("entry_41", "Yes."))) + assertEquals("one entry stands for one message", 1, controller.state.value.outgoing.size) + // A later frame never lets the same entry settle the other message too. + controller.frame(AidenBotSessionEventPayload.State(AidenBotSessionStateView(AidenBotSessionState.RUNNING, false))) + assertEquals(1, controller.state.value.outgoing.size) + controller.frame(AidenBotSessionEventPayload.Entry(userEntry("entry_42", "Yes."))) + assertTrue(controller.state.value.outgoing.isEmpty()) + } + + @Test + fun aDeferredMessageHoldsBackTheOnesBehindIt() = runTest { + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope) + controller.refetch() + transport.sendFailures += IOException("dropped") + assertTrue(controller.send("First")) + assertTrue(controller.send("Second")) + assertEquals("nothing overtakes the deferred head", listOf("First"), transport.sends.map { it.first }) + assertEquals(listOf("First", "Second"), controller.state.value.outgoing.map { it.text }) + advanceTimeBy(1_001) + runCurrent() + assertEquals(listOf("First", "First", "Second"), transport.sends.map { it.first }) + assertEquals(transport.sends[0].second, transport.sends[1].second) + } + + @Test + fun aMessageThatKeepsFailingSaysSoKeepsItsKeyAndTryAgainReplaysIt() = runTest { + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope) + controller.refetch() + transport.sendFailures += listOf( + IOException("one"), + serverError(503, AidenRemoteErrorCode.INTERNAL_ERROR), + serverError(409, AidenRemoteErrorCode.IDEMPOTENCY_IN_FLIGHT) + ) + assertTrue(controller.send("Plan lunch")) + advanceTimeBy(10_000) + runCurrent() + assertEquals(3, transport.sends.size) + assertEquals("every ambiguous failure keeps the key", 1, transport.sends.map { it.second }.toSet().size) + val failed = controller.state.value.outgoing.single() + assertEquals("never dropped", AidenBotOutgoingLook.Failed, controller.state.value.look(failed)) + + assertTrue(controller.retrySend(failed.id)) + assertEquals(transport.sends[0].second, transport.sends[3].second) + assertEquals(AidenBotOutgoingStatus.SENT, controller.state.value.outbox.single().status) + assertEquals("the bubble keeps its identity", failed.id, controller.state.value.outbox.single().id) + } + + @Test + fun aRefusedMessageFailsTryAgainUsesANewKeyAndOnlyAFailedOneCanBeRemoved() = runTest { + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope) + controller.refetch() + transport.sendFailures += serverError(400, AidenRemoteErrorCode.INVALID_REQUEST) + assertTrue(controller.send("Plan lunch")) + val failed = controller.state.value.outgoing.single() + assertEquals(AidenBotOutgoingStatus.FAILED, failed.status) + transport.sendFailures += serverError(400, AidenRemoteErrorCode.INVALID_REQUEST) + assertTrue(controller.retrySend(failed.id)) + assertNotEquals(transport.sends[0].second, transport.sends[1].second) + + transport.sendGate = CompletableDeferred() + val another = async { controller.send("Another") } + runCurrent() + controller.discardSend(controller.state.value.outbox.last().id) + assertEquals("an unsettled message is never removed", 2, controller.state.value.outbox.size) + controller.discardSend(failed.id) + assertEquals(listOf("Another"), controller.state.value.outbox.map { it.text }) + transport.sendGate!!.complete(Unit) + assertTrue(another.await()) + } + + @Test + fun messagesSentBeforeConnectingShowAtOnceAndGoOutOnLoad() = runTest { + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, activitySupported = true) + assertTrue(controller.state.value.isConnecting) + assertTrue(controller.send("Plan lunch")) + assertTrue(transport.sends.isEmpty()) + assertEquals(listOf("Plan lunch"), controller.state.value.outgoing.map { it.text }) + assertEquals( + "the line says Getting ready before the Mac does", + AidenBotActivityPhase.PREPARING, + aidenBotStatusLine(controller.state.value.copy(knownState = AidenBotSessionState.IDLE), started)!!.display.phase + ) + controller.refetch() + runCurrent() + assertEquals(listOf("Plan lunch"), transport.sends.map { it.first }) + assertFalse(controller.state.value.isConnecting) + } + + @Test + fun aQueuedMessageReadsQueuedOnlyWhenSentWhileTheBotWorksAndTheMacsBubbleReplacesIt() = runTest { + val transport = FakeTransport(runningSession) + transport.receiptIds += "23" + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, activitySupported = true) + controller.refetch() + transport.sendGate = CompletableDeferred() + val sending = async { controller.send("Also a list.", AidenStreamInputMode.STEER) } + runCurrent() + val bubble = controller.state.value.outgoing.single() + assertEquals(AidenBotOutgoingLook.Queued(AidenStreamInputMode.STEER), controller.state.value.look(bubble)) + // The Mac's Queued frame beats the receipt: one bubble, the Mac's. + controller.frame(AidenBotSessionEventPayload.Queued(listOf(AidenBotQueuedMessage("23", AidenStreamInputMode.STEER, "Also a list.")))) + assertTrue(controller.state.value.outgoing.isEmpty()) + transport.sendGate!!.complete(Unit) + assertTrue(sending.await()) + assertTrue(controller.state.value.outgoing.isEmpty()) + // The turn takes it in: the queue empties, and until its entry lands it shows plainly. + controller.frame(AidenBotSessionEventPayload.Queued(emptyList())) + assertEquals(AidenBotOutgoingLook.Sent, controller.state.value.look(controller.state.value.outgoing.single())) + controller.frame(AidenBotSessionEventPayload.Entry(userEntry("entry_43", "Also a list.", "23"))) + assertTrue(controller.state.value.outbox.isEmpty()) + + // Sent while idle, it never reads Queued, even once the turn it started runs. + transport.session = runningSession.copy(seq = 50, state = AidenBotSessionState.IDLE, activity = null) + controller.refetch() + transport.receiptState = AidenBotSessionState.RUNNING + transport.sendGate = CompletableDeferred() + val idleSend = async { controller.send("Plan lunch") } + runCurrent() + controller.frame(AidenBotSessionEventPayload.State(AidenBotSessionStateView(AidenBotSessionState.RUNNING, false))) + assertEquals(AidenBotOutgoingLook.Sending, controller.state.value.look(controller.state.value.outgoing.single())) + transport.sendGate!!.complete(Unit) + idleSend.await() + } + + @Test + fun anOlderMacNeverShowsQueuedOrTheLine() = runTest { + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope) + controller.refetch() + transport.sendGate = CompletableDeferred() + val sending = async { controller.send("Also a list.") } + runCurrent() + assertEquals(AidenBotOutgoingLook.Sending, controller.state.value.look(controller.state.value.outgoing.single())) + assertNull(aidenBotStatusLine(controller.state.value, started)) + transport.sendGate!!.complete(Unit) + sending.await() + assertEquals(AidenBotOutgoingLook.Sent, controller.state.value.look(controller.state.value.outgoing.single())) + } + + @Test + fun aReceiptDoesNotRollBackNewerFrames() = runTest { + val idle = runningSession.copy(state = AidenBotSessionState.IDLE, activity = null) + val transport = FakeTransport(idle) + val controller = AidenBotSessionController(idle.botId, transport, backgroundScope) + controller.refetch() + transport.sendGate = CompletableDeferred() + val sending = async { controller.send("Plan lunch") } + runCurrent() + // The turn already ran and finished on the Mac before the receipt (RUNNING) arrives. + controller.frame(AidenBotSessionEventPayload.State(AidenBotSessionStateView(AidenBotSessionState.RUNNING, false))) + controller.frame(AidenBotSessionEventPayload.State(AidenBotSessionStateView(AidenBotSessionState.IDLE, false))) + transport.sendGate!!.complete(Unit) + sending.await() + assertEquals(AidenBotSessionState.IDLE, controller.state.value.state) + } + + private fun store(root: java.io.File, clock: () -> Long, maxBytes: Int = AidenBotSessionStore.MAX_BYTES) = + AidenBotSessionStore(root, io = { it.run() }, nowMs = clock, maxBytes = maxBytes) + + private val chatKey = AidenBotSessionKey("instance_fixture_01", "device_fixture_01", "bot_fixture_01") + + @Test + fun anOutboxOutlivesLeavingTheChatAndProcessDeathAndGoesOutUnderItsKey() = runTest { + val root = tempFolder.newFolder() + var clock = started + val transport = FakeTransport(runningSession) + val first = store(root, { clock }) + val leaving = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = first, nowMs = { clock } + ) + assertTrue(leaving.send("Plan lunch")) + val key = leaving.state.value.outbox.single().key + // Left before the disk was even read: the messages wait in the store, not lost. + leaving.stopFollowing() + val offline = object : AidenBotSessionTransport by transport { + override suspend fun session(botId: String): AidenBotSession = throw IOException("offline") + } + val early = AidenBotSessionController( + runningSession.botId, offline, backgroundScope, sessionKey = chatKey, store = first, nowMs = { clock } + ) + early.start() + runCurrent() + assertEquals(listOf("Plan lunch"), early.state.value.outgoing.map { it.text }) + early.stopFollowing() + + // Back to the chat in the same run: shown at once, from memory. + val back = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = first, nowMs = { clock } + ) + assertEquals(listOf("Plan lunch"), back.state.value.outgoing.map { it.text }) + // The chat left behind can no longer write it. + assertFalse(first.storeOutbox(chatKey, leaving, emptyList())) + back.stopFollowing() + + // The process died: a new store reads it from disk, off the main thread, before anything goes out. + clock += 60_000 + val relaunched = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = store(root, { clock }), nowMs = { clock } + ) + assertTrue("nothing is read during composition", relaunched.state.value.outbox.isEmpty()) + assertTrue(relaunched.send("And dinner")) + relaunched.refetch() + runCurrent() + assertEquals("the older message goes first", listOf("Plan lunch", "And dinner"), relaunched.state.value.outbox.map { it.text }) + assertEquals(listOf("Plan lunch", "And dinner"), transport.sends.map { it.first }) + assertEquals("under the key it was given before the process died", key, transport.sends[0].second) + } + + @Test + fun aRestoreForAnotherPairingIsRefused() = runTest { + val root = tempFolder.newFolder() + val transport = FakeTransport(runningSession) + val first = store(root, { started }) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = first) + assertTrue(controller.send("Plan lunch")) + runCurrent() + // The same chat after a re-pair (another deviceId) finds this file where its own would be. + val repaired = chatKey.copy(deviceId = "device_fixture_02") + val theirs = first.fileFor(repaired)!! + theirs.parentFile!!.mkdirs() + first.fileFor(chatKey)!!.copyTo(theirs) + val other = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = repaired, store = store(root, { started }) + ) + other.start() + runCurrent() + assertTrue(other.state.value.outbox.isEmpty()) + assertFalse("the misplaced file is moved out of the way", theirs.exists()) + assertEquals("set aside, never deleted", 1, theirs.parentFile!!.listFiles()!!.count { it.name.endsWith(".unreadable") }) + assertEquals(AidenBotSessionNotice.OUTBOX_UNRESTORABLE, other.state.value.notice) + other.stopFollowing() + } + + @Test + fun aControllerThatLostTheChatToAnotherStopsSending() = runTest { + val sessions = AidenBotSessionStore(null, io = { it.run() }) + val transport = FakeTransport(runningSession) + val older = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) + older.refetch() + runCurrent() + AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) + assertTrue(older.send("From the old screen")) + assertTrue("its write was refused, so it sends nothing", transport.sends.isEmpty()) + } + + @Test + fun leavingTheChatCancelsTheRequestUnderWayAndTheNextVisitReplaysItsKey() = runTest { + val sessions = AidenBotSessionStore(null, io = { it.run() }) + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) + controller.refetch() + runCurrent() + transport.sendGate = CompletableDeferred() + val sending = async { controller.send("Plan lunch") } + runCurrent() + controller.stopFollowing() + runCurrent() + assertTrue(sending.isCompleted) + assertEquals(AidenBotOutgoingStatus.SENDING, controller.state.value.outbox.single().status) + transport.sendGate = null + val next = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) + next.refetch() + runCurrent() + assertEquals(2, transport.sends.size) + assertEquals("the same request again", transport.sends[0].second, transport.sends[1].second) + assertEquals(AidenBotOutgoingStatus.SENT, next.state.value.outbox.single().status) + } + + @Test + fun aMessageThatWouldOverflowTheStoredOutboxIsRefusedNotWritten() = runTest { + val root = tempFolder.newFolder() + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = store(root, { started }, maxBytes = 1_500) + ) + val text = "x".repeat(400) + var taken = 0 + while (controller.send(text) && taken < 20) taken += 1 + runCurrent() + assertTrue("some fit, then one was refused", taken in 1..5) + assertEquals(AidenBotSessionNotice.OUTBOX_FULL, controller.state.value.notice) + assertEquals(taken, controller.state.value.outbox.size) + // What was kept reads back whole. + val relaunched = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = store(root, { started }, maxBytes = 1_500) + ) + relaunched.start() + runCurrent() + assertEquals(taken, relaunched.state.value.outbox.size) + relaunched.stopFollowing() + } + + @Test + fun aMessageUnsentForADayComesBackAsCouldntSendAndABadRecordCostsOnlyItself() = runTest { + val root = tempFolder.newFolder() + var clock = started + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = store(root, { clock }), nowMs = { clock } + ) + assertTrue(controller.send("Plan lunch")) + assertTrue(controller.send("And dinner")) + runCurrent() + controller.stopFollowing() + // Corrupt the second record on disk. + val file = root.walkTopDown().single { it.isFile } + file.writeText(file.readText().replace("\"text\":\"And dinner\"", "\"text\":7")) + + clock += AidenBotSessionStore.LIFETIME_MS + val later = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = store(root, { clock }), nowMs = { clock } + ) + later.refetch() + runCurrent() + val restored = later.state.value.outgoing + assertEquals(listOf("Plan lunch"), restored.map { it.text }) + assertEquals(AidenBotOutgoingLook.Failed, later.state.value.look(restored.single())) + // The bad record is never dropped silently: the file as it was is kept beside the chat's, and the chat says so. + assertEquals(AidenBotSessionNotice.OUTBOX_UNRESTORABLE, later.state.value.notice) + val aside = root.walkTopDown().single { it.name.endsWith(".unreadable") } + assertTrue(aside.readText().contains("\"text\":7")) + assertTrue("an expired message never goes out late", transport.sends.isEmpty()) + + // In memory too: a message deferred for a day says so on the next try. + val inMemory = AidenBotSessionController(runningSession.botId, transport, backgroundScope, nowMs = { clock }) + assertTrue(inMemory.send("Breakfast")) + clock += AidenBotSessionStore.LIFETIME_MS + inMemory.refetch() + runCurrent() + assertEquals(AidenBotOutgoingStatus.FAILED, inMemory.state.value.outbox.single().status) + assertTrue(transport.sends.isEmpty()) + } + + @Test + fun losingAccessOnTheStreamPurgesTheOutboxEverywhereAndSaysSo() = runTest { + for (status in listOf(401, 403)) { + val root = tempFolder.newFolder() + val sessions = store(root, { started }) + val transport = FakeTransport(runningSession) + transport.feeds += flow { throw serverError(status, AidenRemoteErrorCode.AUTHENTICATION_REQUIRED) } + val controller = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions, nowMs = { started } + ) + assertTrue(controller.send("Plan lunch")) + runCurrent() + assertTrue(root.walkTopDown().any { it.isFile }) + controller.start() + runCurrent() + assertTrue("$status", controller.state.value.outbox.isEmpty()) + assertEquals(AidenBotSessionNotice.ACCESS_LOST, controller.state.value.notice) + assertTrue("nothing unsent stays on disk after $status", root.walkTopDown().none { it.isFile }) + assertNull(sessions.transcript(chatKey)) + } + } + + // PR #479 review: a disk restore whose result lands after an access-loss purge must not bring the purged text back. + @Test + fun aRestoreThatLandsAfterAnAccessLossPurgeBringsBackNothingUnsent() = runTest { + val root = tempFolder.newFolder() + val offline = object : AidenBotSessionTransport by FakeTransport(runningSession) { + override suspend fun session(botId: String): AidenBotSession = throw IOException("offline") + } + val before = AidenBotSessionController( + runningSession.botId, offline, backgroundScope, sessionKey = chatKey, store = store(root, { started }), nowMs = { started } + ) + assertTrue(before.send("Plan lunch")) + runCurrent() + before.stopFollowing() + assertTrue("the unsent message is on disk", root.walkTopDown().any { it.isFile }) + + // A fresh controller and store: the disk read waits on [io], and the session read on [reply]. + val io = ArrayDeque() + val fresh = AidenBotSessionStore(root, io = { io.addLast(it) }, nowMs = { started }) + val reply = CompletableDeferred() + val gated = object : AidenBotSessionTransport by FakeTransport(runningSession) { + override suspend fun session(botId: String): AidenBotSession = reply.await() + } + val controller = AidenBotSessionController( + runningSession.botId, gated, backgroundScope, sessionKey = chatKey, store = fresh, nowMs = { started } + ) + backgroundScope.launch { controller.refetch() } + runCurrent() + assertEquals("the restore is queued behind the disk", 1, io.size) + + // The Mac answers 403, so its purge is queued on the main thread first. Then the disk read + // finishes, and its result queues behind that purge. + reply.completeExceptionally(serverError(403, AidenRemoteErrorCode.AUTHENTICATION_REQUIRED)) + while (io.isNotEmpty()) io.removeFirst().run() + runCurrent() + while (io.isNotEmpty()) io.removeFirst().run() + runCurrent() + + assertTrue(controller.state.value.accessLost) + assertTrue("nothing unsent is shown", controller.state.value.outgoing.isEmpty()) + assertTrue("nothing unsent is kept on disk", root.walkTopDown().none { it.isFile }) + } + + // PR #479 review: a purge leaves the chat read and empty, so a restore queued before it reads and writes nothing. + @Test + fun aRestoreQueuedBeforeAStorePurgeReadsNothingBackAndLeavesNoFile() = runTest { + val root = tempFolder.newFolder() + val owner = Any() + val seeded = store(root, { started }) + seeded.adoptOutbox(chatKey, owner) + seeded.restoreOutbox(chatKey, owner) + seeded.storeOutbox(chatKey, owner, listOf(AidenBotOutgoingMessage.create("Plan lunch", null, false, null, started))) + assertTrue(root.walkTopDown().any { it.isFile }) + + val io = ArrayDeque() + val later = AidenBotSessionStore(root, io = { io.addLast(it) }, nowMs = { started }) + later.adoptOutbox(chatKey, owner) + val restore = async { later.restoreOutbox(chatKey, owner) } + runCurrent() + later.purge(chatKey) + while (io.isNotEmpty()) io.removeFirst().run() + runCurrent() + + assertTrue(restore.await().messages.isEmpty()) + assertTrue("no file survives the purge", root.walkTopDown().none { it.isFile }) + } + + @Test + fun aBotWhoseAccessChangedAndAnUnpairedMacKeepNothingUnsent() = runTest { + val root = tempFolder.newFolder() + val sessions = store(root, { started }) + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions, nowMs = { started } + ) + controller.refetch() + runCurrent() + transport.sendFailures += IOException("dropped") + assertTrue(controller.send("Plan lunch")) + controller.frame( + AidenBotSessionEventPayload.State( + AidenBotSessionStateView(AidenBotSessionState.INTERRUPTED, true, AidenBotSessionBlock.ACCESS_CHANGED) + ) + ) + assertTrue(controller.state.value.outbox.isEmpty()) + assertEquals(AidenBotSessionNotice.ACCESS_LOST, controller.state.value.notice) + + // Unpair: every chat of the Mac goes, and an open chat can't write it back. + val other = AidenBotSessionController( + "bot_fixture_02", transport, backgroundScope, sessionKey = chatKey.copy(botId = "bot_fixture_02"), store = sessions + ) + assertTrue(other.send("Hello")) + runCurrent() + assertTrue(root.walkTopDown().any { it.isFile }) + sessions.purgeInstance(chatKey.instanceId) + assertTrue(root.walkTopDown().none { it.isFile }) + assertTrue(other.send("Still there?")) + assertTrue(root.walkTopDown().none { it.isFile }) + } + + @Test + fun aChatOpensOnItsCachedTranscriptAndShowsSkeletonsOnlyWithNothingToShow() = runTest { + val sessions = AidenBotSessionStore(null, io = { it.run() }) + val transport = FakeTransport(runningSession) + val fresh = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) + assertTrue(fresh.state.value.showsSkeleton) + fresh.refetch() + assertFalse(fresh.state.value.showsSkeleton) + fresh.frame(AidenBotSessionEventPayload.Entry(userEntry("entry_44", "Thanks"))) + fresh.stopFollowing() + + val reopened = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) + assertEquals(listOf("entry_30", "entry_44"), reopened.state.value.entries.map { it.id }) + assertFalse(reopened.state.value.showsSkeleton) + assertTrue("still connecting, but never blocking", reopened.state.value.isConnecting) + val otherPairing = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey.copy(deviceId = "device_fixture_02"), store = sessions + ) + assertTrue(otherPairing.state.value.showsSkeleton) + } + + // --- Task 10: what the chat draws while the Mac isn't reachable (R14) --- + + @Test + fun aChatSaysConnectingThenOffersTryAgainWithStillSkeletonsWhenItsFirstReadFails() = runTest { + val transport = FakeTransport(runningSession) + transport.sessionFailures += IOException("Mac asleep") + transport.feeds += flow { awaitCancellation() } + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, activitySupported = true) + assertEquals(AidenBotConnectionHint.CONNECTING, aidenBotConnectionHint(controller.state.value, started)) + assertEquals(AidenBotSkeletonLook.SHIMMERING, aidenBotSkeletonLook(controller.state.value)) + + controller.start() + runCurrent() + // Never an endless shimmer: the read failed, so the skeletons rest and Try again shows. + assertEquals(AidenBotConnectionHint.UNREACHABLE, aidenBotConnectionHint(controller.state.value, started)) + assertEquals(AidenBotSkeletonLook.STILL, aidenBotSkeletonLook(controller.state.value)) + assertTrue("the composer still takes a message", controller.state.value.canSend) + + // Try again reads the session: the hint and skeletons give way to the chat. + assertTrue(controller.refetch()) + assertNull(aidenBotConnectionHint(controller.state.value, started)) + assertEquals(AidenBotSkeletonLook.HIDDEN, aidenBotSkeletonLook(controller.state.value)) + controller.stopFollowing() + } + + @Test + fun aCachedChatShowsItsTranscriptNotSkeletonsAndAMissingBotShowsNeitherHintNorSkeletons() = runTest { + val sessions = AidenBotSessionStore(null, io = { it.run() }) + val transport = FakeTransport(runningSession) + val first = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) + first.refetch() + first.stopFollowing() + + transport.sessionFailures += IOException("Mac asleep") + val reopened = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) + reopened.refetch() + assertEquals(AidenBotSkeletonLook.HIDDEN, aidenBotSkeletonLook(reopened.state.value)) + assertFalse(reopened.state.value.entries.isEmpty()) + + val gone = FakeTransport(runningSession) + gone.sessionFailures += serverError(404) + val missing = AidenBotSessionController(runningSession.botId, gone, backgroundScope) + missing.refetch() + assertTrue(missing.state.value.botMissing) + assertNull(aidenBotConnectionHint(missing.state.value, started)) + assertEquals(AidenBotSkeletonLook.HIDDEN, aidenBotSkeletonLook(missing.state.value)) + } + + @Test + fun aDroppedFeedSaysReconnectingOnTheLineAloneAndConnectingWhenIdle() = runTest { + val dropped = flow { throw IOException("Wi-Fi dropped") } + val working = FakeTransport(activitySession) + working.feeds += flow { + emit(snapshotOf(activitySession)) + throw IOException("Wi-Fi dropped") + } + working.feeds += flow { awaitCancellation() } + val running = AidenBotSessionController(activitySession.botId, working, backgroundScope, activitySupported = true) + running.start() + runCurrent() + assertEquals(AidenBotActivityPhase.RECONNECTING, aidenBotStatusLine(running.state.value, started)!!.display.phase) + assertNull("the line already says it", aidenBotConnectionHint(running.state.value, started)) + running.stopFollowing() + + val idleSession = activitySession.copy(state = AidenBotSessionState.IDLE, activity = null) + val quiet = FakeTransport(idleSession) + quiet.feeds += flow { + emit(snapshotOf(idleSession)) + throw IOException("Wi-Fi dropped") + } + quiet.feeds += dropped + val idle = AidenBotSessionController(idleSession.botId, quiet, backgroundScope, activitySupported = true) + idle.start() + runCurrent() + assertNull(aidenBotStatusLine(idle.state.value, started)) + assertEquals(AidenBotConnectionHint.CONNECTING, aidenBotConnectionHint(idle.state.value, started)) + idle.stopFollowing() + } + + @Test + fun aCleanCloseDoesNotFlashReconnecting() = runTest { + val transport = FakeTransport(runningSession) + transport.feeds += flow { + emit(snapshotOf(runningSession)) + emit(AidenBotSessionEvent(runningSession.botId, runningSession.epoch, runningSession.seq + 1, AidenBotSessionEventPayload.Closed)) + } + transport.feeds += flow { + delay(300) + emit(snapshotOf(runningSession)) + awaitCancellation() + } + val controller = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, activitySupported = true, reconnectDelayMillis = 200 + ) + controller.start() + runCurrent() + assertFalse(controller.state.value.streamDown) + advanceTimeBy(600) + runCurrent() + assertFalse("the reconnect came back before it would show", controller.state.value.streamDown) + controller.stopFollowing() + + // A close whose reconnect never answers does say so, after the debounce. + val stuck = FakeTransport(runningSession) + stuck.feeds += flow { emit(snapshotOf(runningSession)) } + stuck.feeds += flow { awaitCancellation() } + val waiting = AidenBotSessionController( + runningSession.botId, stuck, backgroundScope, activitySupported = true, reconnectDelayMillis = 200 + ) + waiting.start() + runCurrent() + assertFalse(waiting.state.value.streamDown) + advanceTimeBy(2_500) + runCurrent() + assertTrue(waiting.state.value.streamDown) + waiting.stopFollowing() + } + + // --- R18: every turn control applies at once --- + + @Test + fun stopSaysStoppingAtOnceRevertsOnFailureAndANewRunClearsIt() = runTest { + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, activitySupported = true) + controller.refetch() + transport.stopGate = CompletableDeferred() + transport.stopFailures += IOException("offline") + val stopping = async { controller.stop() } + runCurrent() + assertTrue(controller.state.value.isStopping) + assertEquals(AidenBotActivityPhase.STOPPING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + assertFalse("a second tap sends nothing", controller.stop()) + transport.stopGate!!.complete(Unit) + assertFalse(stopping.await()) + assertFalse(controller.state.value.isStopping) + assertEquals(AidenBotActivityPhase.APP, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + + // Stop reaches the Mac, but a queued follow-up starts a new run before the Mac's word. + transport.stopState = AidenBotSessionState.RUNNING + transport.stopGate = CompletableDeferred() + val again = async { controller.stop() } + runCurrent() + controller.frame(AidenBotSessionEventPayload.Activity(AidenBotSessionActivity("thinking", null, started + 5_000))) + assertFalse("the new run has nothing to stop", controller.state.value.isStopping) + transport.stopGate!!.complete(Unit) + assertTrue(again.await()) + assertFalse(controller.state.value.isStopping) + assertEquals(AidenBotActivityPhase.THINKING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + } + + @Test + fun stopPressedWithNothingRunningNeverMakesTheNextTurnSayStopping() = runTest { + val idle = runningSession.copy(state = AidenBotSessionState.IDLE, activity = null, question = colourQuestion) + val transport = FakeTransport(idle) + val controller = AidenBotSessionController(idle.botId, transport, backgroundScope, activitySupported = true) + controller.refetch() + assertTrue(controller.stop()) + controller.frame(AidenBotSessionEventPayload.State(AidenBotSessionStateView(AidenBotSessionState.RUNNING, false))) + assertFalse(controller.state.value.isStopping) + assertEquals(AidenBotActivityPhase.PREPARING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + } + + @Test + fun resumeAndDismissApplyAtOnceAndRollBackOnlyWhatNothingNewerReplaced() = runTest { + val transport = FakeTransport(interruptedSession) + val controller = AidenBotSessionController(interruptedSession.botId, transport, backgroundScope, activitySupported = true) + controller.refetch() + + // Resume: "Picking up where it left off" at once; an unrelated frame, then a failure, rolls back. + transport.resumeGate = CompletableDeferred() + transport.failNextResume = true + val resuming = async { controller.resume() } + runCurrent() + assertFalse(controller.state.value.isInterrupted) + assertEquals(AidenBotActivityPhase.RESUMING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + controller.frame(AidenBotSessionEventPayload.Partial("Your first meeting is at 9:30")) + transport.resumeGate!!.complete(Unit) + assertFalse(resuming.await()) + assertTrue(controller.state.value.isInterrupted) + assertEquals("Aiden couldn’t reach your Mac. Try again.", controller.state.value.actionError) + + // Dismiss: gone at once; the Mac's own state frame in between wins over the rollback. + transport.dismissGate = CompletableDeferred() + transport.dismissFailures += IOException("offline") + val dismissing = async { controller.dismiss() } + runCurrent() + assertFalse(controller.state.value.isInterrupted) + controller.frame(AidenBotSessionEventPayload.State(AidenBotSessionStateView(AidenBotSessionState.RUNNING, false))) + transport.dismissGate!!.complete(Unit) + assertFalse(dismissing.await()) + assertEquals(AidenBotSessionState.RUNNING, controller.state.value.state) + + // An unrelated frame alone still lets a failed Dismiss come back. + transport.session = interruptedSession.copy(seq = 40) + controller.refetch() + transport.dismissGate = CompletableDeferred() + transport.dismissFailures += IOException("offline") + val second = async { controller.dismiss() } + runCurrent() + controller.frame(AidenBotSessionEventPayload.Partial("x")) + transport.dismissGate!!.complete(Unit) + assertFalse(second.await()) + assertTrue(controller.state.value.isInterrupted) + } + + @Test + fun anAnswerAndAnApprovalHideAtOnceAndComeBackOnlyIfNothingNewerReplacedThem() = runTest { + val transport = FakeTransport(sessionEndingWith().copy(question = colourQuestion)) + val controller = AidenBotSessionController(transport.session.botId, transport, backgroundScope) + controller.refetch() + val answer = AidenQuestionRespondRequest(false, listOf(AidenQuestionAnswer.Option(0, "Blue"))) + transport.answerGate = CompletableDeferred() + transport.failNextAnswer = true + val answering = async { controller.answerQuestion(answer) } + runCurrent() + assertNull(controller.state.value.session?.question) + controller.frame(AidenBotSessionEventPayload.Partial("x")) + transport.answerGate!!.complete(Unit) + assertFalse(answering.await()) + assertEquals(colourQuestion, controller.state.value.session?.question) + + // The Mac moved on to another question meanwhile: the old one stays gone. + transport.answerGate = CompletableDeferred() + transport.failNextAnswer = true + val again = async { controller.answerQuestion(answer) } + runCurrent() + val next = colourQuestion.copy(waitId = "6f0c1a2e-7b3d-4e9a-8c61-0d2e3f4a5b6c") + controller.frame(AidenBotSessionEventPayload.Question(next)) + transport.answerGate!!.complete(Unit) + assertFalse(again.await()) + assertEquals(next, controller.state.value.session?.question) + + // Approvals: hidden at once, back after an unrelated frame and a failure. + val (approvals, approvalController) = approvalController(mailApproval, backgroundScope) + approvalController.refetch() + approvals.approvalGate = CompletableDeferred() + approvals.approvalFailures += IOException("offline") + val deciding = async { approvalController.respondToApproval(AidenApprovalDecision.DENY) } + runCurrent() + assertNull(approvalController.state.value.session?.approval) + approvalController.frame(AidenBotSessionEventPayload.Partial("x")) + approvals.approvalGate!!.complete(Unit) + assertFalse(deciding.await()) + assertEquals(mailApproval, approvalController.state.value.session?.approval) + assertEquals("Aiden couldn’t send that. Try again.", approvalController.state.value.actionError) + } + + @Test + fun aProposalAnswerSettlesAtOnceAndGoesBackToPendingOnFailure() = runTest { + val transport = FakeTransport(cardsSession) + val controller = AidenBotSessionController(cardsSession.botId, transport, backgroundScope) + controller.refetch() + transport.proposalGate = CompletableDeferred() + transport.proposalFailures += IOException("offline") + val answering = async { controller.respondToProposal(pendingProposalId, AidenBotRoutineProposalDecision.ACCEPT) } + runCurrent() + assertEquals(AidenBotRoutineProposalStatus.ACCEPTED, controller.state.value.proposal(pendingProposalId).status) + controller.frame(AidenBotSessionEventPayload.Partial("x")) + transport.proposalGate!!.complete(Unit) + assertFalse(answering.await()) + assertEquals(AidenBotRoutineProposalStatus.PENDING, controller.state.value.proposal(pendingProposalId).status) + assertEquals(AidenBotProposalPhase.FAILED, controller.state.value.proposalResponses[pendingProposalId]) + } + + // Task 8 review: an optimistic Stop or Resume never leaves the line on the run before's phase. + @Test + fun theLineNeverShowsAStalePhaseAfterResumeStopOrANewTurn() = runTest { + val paused = interruptedSession.copy(partial = null, activity = AidenBotSessionActivity("app", "Notion", started)) + val transport = FakeTransport(paused) + val controller = AidenBotSessionController(paused.botId, transport, backgroundScope, activitySupported = true) + controller.refetch() + assertTrue(controller.resume()) + assertEquals( + "the Mac took the Resume: its intent, not the paused run's app", + AidenBotActivityPhase.RESUMING, + aidenBotStatusLine(controller.state.value, started)!!.display.phase + ) + controller.frame(AidenBotSessionEventPayload.Activity(AidenBotSessionActivity("searching", null, started + 9_000))) + assertEquals(AidenBotActivityPhase.SEARCHING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + + // Stopped: the next turn starts at "Getting ready", not at the stopped run's phase. + assertTrue(controller.stop()) + assertNull(aidenBotStatusLine(controller.state.value, started)) + controller.frame(AidenBotSessionEventPayload.State(AidenBotSessionStateView(AidenBotSessionState.RUNNING, false))) + assertEquals(AidenBotActivityPhase.PREPARING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + } + + // Fix round 1: a frozen request goes out exactly as frozen. + @Test + fun aFrozenSteerKeepsItsModeWhenTheMacsAnnouncementChanges() = runTest { + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, activitySupported = true) + controller.refetch() + transport.sendFailures += IOException("dropped") + assertTrue(controller.send("Keep it vegetarian.", AidenStreamInputMode.STEER)) + controller.setActivitySupported(false) + advanceTimeBy(1_001) + runCurrent() + assertEquals(listOf(AidenStreamInputMode.STEER, AidenStreamInputMode.STEER), transport.sendModes) + assertEquals("one request under one key", 1, transport.sends.map { it.second }.toSet().size) + } + + // Fix round 1: an entry naming another submission never makes an exhausted message vanish. + @Test + fun anExhaustedMessageWhoseLookAlikeEntryIsAnothersFailsVisibly() = runTest { + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, activitySupported = true) + controller.refetch() + transport.sendFailures += listOf(IOException("one"), IOException("two"), IOException("three")) + assertTrue(controller.send("Yes.")) + // Another device's "Yes." lands while this one waits for its retry. + controller.frame(AidenBotSessionEventPayload.Entry(userEntry("entry_45", "Yes.", "99"))) + assertTrue("an unsettled request may wait on it", controller.state.value.outgoing.isEmpty()) + advanceTimeBy(10_000) + runCurrent() + assertEquals(3, transport.sends.size) + val failed = controller.state.value.outgoing.single() + assertEquals(AidenBotOutgoingLook.Failed, controller.state.value.look(failed)) + controller.frame(AidenBotSessionEventPayload.Partial("Sure")) + assertEquals("still there after the next frame", listOf(failed.id), controller.state.value.outgoing.map { it.id }) + assertTrue(controller.retrySend(failed.id)) + assertEquals("Try again replays the key", transport.sends[0].second, transport.sends[3].second) + } + + @Test + fun anActivityFrameAfterAResumeTapSaysWhatTheRunIsDoing() = runTest { + val transport = FakeTransport(interruptedSession.copy(partial = null)) + val controller = AidenBotSessionController(interruptedSession.botId, transport, backgroundScope, activitySupported = true) + controller.refetch() + transport.resumeGate = CompletableDeferred() + val resuming = async { controller.resume() } + runCurrent() + assertEquals(AidenBotActivityPhase.RESUMING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + // A cleared activity says nothing about the resumed run: it still picks up. + controller.frame(AidenBotSessionEventPayload.Activity(null)) + assertEquals(AidenBotActivityPhase.RESUMING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + controller.frame(AidenBotSessionEventPayload.Activity(AidenBotSessionActivity("reading", null, started))) + assertEquals(AidenBotActivityPhase.READING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + transport.resumeGate!!.complete(Unit) + assertTrue(resuming.await()) + assertEquals(AidenBotActivityPhase.READING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + } + + // Fix round 1: the disk is read on the io thread, and nothing goes out before it. + @Test + fun aSlowDiskReadHoldsBackNewMessagesUntilTheOlderOnesAreBack() = runTest { + val root = tempFolder.newFolder() + val transport = FakeTransport(runningSession) + val offline = object : AidenBotSessionTransport by transport { + override suspend fun session(botId: String): AidenBotSession = throw IOException("offline") + } + val before = AidenBotSessionController(runningSession.botId, offline, backgroundScope, sessionKey = chatKey, store = store(root, { started })) + assertTrue(before.send("Plan lunch")) + runCurrent() + before.stopFollowing() + + val io = ArrayDeque() + val slow = AidenBotSessionStore(root, io = { io.addLast(it) }, nowMs = { started }) + val relaunched = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = slow) + assertTrue(relaunched.send("And dinner")) + relaunched.refetch() + runCurrent() + assertTrue("connected, but the older message isn't back yet", transport.sends.isEmpty()) + while (io.isNotEmpty()) io.removeFirst().run() + runCurrent() + while (io.isNotEmpty()) io.removeFirst().run() + assertEquals(listOf("Plan lunch", "And dinner"), transport.sends.map { it.first }) + } + + // --- Final fix wave --- + + // FR-I2: a file this build can't read is set aside, never deleted, and the chat says so. + @Test + fun anOutboxFileThatCantBeReadIsSetAsideAndTheChatSaysSo() = runTest { + val maxBytes = 1_500 + val damages: List Unit>> = listOf( + "garbage" to { file -> file.writeText("not an outbox") }, + "a newer version" to { file -> file.writeText(file.readText().replace("\"version\":1", "\"version\":2")) }, + "oversized" to { file -> file.writeText(" ".repeat(2 * maxBytes + 1)) }, + "unopenable" to { file -> assertTrue(file.setReadable(false)) } + ) + for ((name, damage) in damages) { + val root = tempFolder.newFolder() + val transport = FakeTransport(runningSession) + val writer = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = store(root, { started }, maxBytes), nowMs = { started } + ) + assertTrue(writer.send("Plan my week.")) + runCurrent() + writer.stopFollowing() + val file = root.walkTopDown().single { it.isFile } + damage(file) + val damagedSize = file.length() + try { + val reader = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = store(root, { started }, maxBytes), nowMs = { started } + ) + reader.start() + runCurrent() + assertEquals(name, AidenBotSessionNotice.OUTBOX_UNRESTORABLE, reader.state.value.notice) + assertTrue(name, reader.state.value.outgoing.isEmpty()) + val aside = root.walkTopDown().filter { it.name.endsWith(".unreadable") }.toList() + assertEquals("$name: set aside, never deleted", 1, aside.size) + assertEquals("$name: kept as it was", damagedSize, aside.single().length()) + + // The chat writes its own outbox afresh beside it. + assertTrue(reader.send("Again")) + runCurrent() + assertTrue(name, file.exists()) + assertEquals(name, 1, root.walkTopDown().count { it.name.endsWith(".unreadable") }) + reader.stopFollowing() + } finally { + root.walkTopDown().forEach { it.setReadable(true) } + } + } + } + + // Controller polish: a restore that can't run still ends, so the chat sends and says so. + @Test + fun aRestoreThatCantRunStillLetsTheChatSendAndSaysSo() = runTest { + val root = tempFolder.newFolder() + val transport = FakeTransport(runningSession) + val broken = AidenBotSessionStore(root, io = { throw java.util.concurrent.RejectedExecutionException("shut down") }, nowMs = { started }) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = broken, nowMs = { started }) + assertTrue(controller.send("Plan lunch")) + controller.refetch() + runCurrent() + assertEquals(listOf("Plan lunch"), transport.sends.map { it.first }) + assertEquals(AidenBotSessionNotice.OUTBOX_UNRESTORABLE, controller.state.value.notice) + } + + // Controller polish: a restore that runs past the limit drops nothing; the newest say "Couldn't send". + @Test + fun aRestoreThatRunsPastTheLimitKeepsEveryMessageAndFailsTheNewest() = runTest { + val root = tempFolder.newFolder() + val offline = object : AidenBotSessionTransport by FakeTransport(runningSession) { + override suspend fun session(botId: String): AidenBotSession = throw IOException("offline") + } + val before = AidenBotSessionController(runningSession.botId, offline, backgroundScope, sessionKey = chatKey, store = store(root, { started }), nowMs = { started }) + repeat(15) { assertTrue(before.send("Earlier $it")) } + runCurrent() + before.stopFollowing() + + val io = ArrayDeque() + val slow = AidenBotSessionStore(root, io = { io.addLast(it) }, nowMs = { started }) + val transport = FakeTransport(runningSession) + val relaunched = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = slow, nowMs = { started }) + repeat(10) { assertTrue("the disk isn't read yet", relaunched.send("Later $it")) } + relaunched.start() + runCurrent() + while (io.isNotEmpty()) io.removeFirst().run() + runCurrent() + val outbox = relaunched.state.value.outbox + assertEquals("nothing dropped", 25, outbox.size) + assertEquals(List(15) { "Earlier $it" } + List(10) { "Later $it" }, outbox.map { it.text }) + assertEquals( + List(5) { "Later ${5 + it}" }, + outbox.filter { it.status == AidenBotOutgoingStatus.FAILED }.map { it.text } + ) + assertEquals(AidenBotSessionNotice.OUTBOX_FULL, relaunched.state.value.notice) + // Past the limit, Try again waits until some are sent or removed. + assertFalse(relaunched.retrySend(outbox.last().id)) + relaunched.stopFollowing() + while (io.isNotEmpty()) io.removeFirst().run() + + // A later launch reads every one of them back: the newest are never cut off. + val again = AidenBotSessionController(runningSession.botId, offline, backgroundScope, sessionKey = chatKey, store = store(root, { started }), nowMs = { started }) + again.start() + runCurrent() + assertEquals(25, again.state.value.outbox.size) + again.stopFollowing() + } + + // Controller polish: a controller whose store refused its frozen request sends nothing. + @Test + fun aControllerThatLosesTheChatBeforeItsFirstAttemptSendsNothing() = runTest { + val sessions = AidenBotSessionStore(null, io = { it.run() }) + val transport = FakeTransport(runningSession) + val older = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) + assertTrue("waits for the connection", older.send("From the old screen")) + runCurrent() + // Another screen takes the chat after this one's outbox was read. + AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) + older.refetch() + runCurrent() + assertTrue(transport.sends.isEmpty()) + } + + // A transport that records every key and, while [hung], never answers. + private class HangingTransport(private val delegate: FakeTransport) : AidenBotSessionTransport by delegate { + var hung = false + val keys = mutableMapOf>() + private suspend fun call(action: String, key: UUID?, answer: suspend () -> T): T { + if (key != null) keys.getOrPut(action) { mutableListOf() } += key + if (hung) awaitCancellation() + return answer() + } + override suspend fun session(botId: String) = call("session", null) { delegate.session(botId) } + override suspend fun send(botId: String, text: String, key: UUID, whenBusy: AidenStreamInputMode?) = + call("send", key) { delegate.send(botId, text, key, whenBusy) } + override suspend fun resume(botId: String, key: UUID) = call("resume", key) { delegate.resume(botId, key) } + override suspend fun dismiss(botId: String, key: UUID) = call("dismiss", key) { delegate.dismiss(botId, key) } + override suspend fun stop(botId: String, key: UUID) = call("stop", key) { delegate.stop(botId, key) } + override suspend fun answerQuestion(botId: String, waitId: String, request: AidenQuestionRespondRequest, key: UUID) = + call("answer", key) { delegate.answerQuestion(botId, waitId, request, key) } + override suspend fun respondToApproval(waitId: String, decision: AidenApprovalDecision, key: UUID) = + call("approval", key) { delegate.respondToApproval(waitId, decision, key) } + override suspend fun respondToRoutineProposal(botId: String, proposalId: String, decision: AidenBotRoutineProposalDecision, key: UUID) = + call("proposal", key) { delegate.respondToRoutineProposal(botId, proposalId, decision, key) } + } + + /** Runs [action] against a Mac that never answers: it gives up at the limit, and returns false. */ + private suspend fun kotlinx.coroutines.test.TestScope.givesUpInTime(action: suspend () -> Boolean) { + val call = async { action() } + advanceTimeBy(AidenBotSessionController.REQUEST_LIMIT_MILLIS - 1) + runCurrent() + assertFalse("still waiting just before the limit", call.isCompleted) + advanceTimeBy(2) + runCurrent() + assertTrue(call.isCompleted) + assertFalse(call.await()) + } + + // FR-I3: every Bot session request is bounded, and a timeout keeps the key (it may have arrived). + @Test + fun aMacThatNeverAnswersTimesOutEveryRequestAndTheKeyIsKept() = runTest { + // The session read. + val reading = HangingTransport(FakeTransport(runningSession)).apply { hung = true } + val reader = AidenBotSessionController(runningSession.botId, reading, backgroundScope) + givesUpInTime { reader.refetch() } + assertTrue(reader.state.value.loadFailed) + + // A message: it waits for the next try under its key, and goes out under it. + val sending = HangingTransport(FakeTransport(runningSession)) + val sender = AidenBotSessionController(runningSession.botId, sending, backgroundScope) + sender.refetch() + sending.hung = true + val sent = async { sender.send("Plan lunch") } + advanceTimeBy(AidenBotSessionController.REQUEST_LIMIT_MILLIS + 1) + runCurrent() + assertTrue(sent.await()) + assertTrue(sender.state.value.outbox.single().keepsKey) + sending.hung = false + advanceTimeBy(1_001) + runCurrent() + assertEquals(AidenBotOutgoingStatus.SENT, sender.state.value.outbox.single().status) + assertEquals(1, sending.keys.getValue("send").toSet().size) + + // Stop, Resume and Dismiss: each rolls back, and the next tap replays the key. + val stopping = HangingTransport(FakeTransport(runningSession)) + val stopper = AidenBotSessionController(runningSession.botId, stopping, backgroundScope, activitySupported = true) + stopper.refetch() + stopping.hung = true + givesUpInTime { stopper.stop() } + assertFalse(stopper.state.value.isStopping) + givesUpInTime { stopper.stop() } + assertEquals(1, stopping.keys.getValue("stop").toSet().size) + + val pausing = HangingTransport(FakeTransport(interruptedSession)) + val paused = AidenBotSessionController(interruptedSession.botId, pausing, backgroundScope) + paused.refetch() + pausing.hung = true + givesUpInTime { paused.resume() } + assertTrue(paused.state.value.isInterrupted) + givesUpInTime { paused.dismiss() } + assertTrue(paused.state.value.isInterrupted) + givesUpInTime { paused.resume() } + assertEquals(1, pausing.keys.getValue("resume").toSet().size) + + // A question's answer and an approval's decision come back with their keys kept. + val waiting = sessionEndingWith().copy(question = colourQuestion, approval = mailApproval) + val asking = HangingTransport(FakeTransport(waiting)) + val asked = AidenBotSessionController(waiting.botId, asking, backgroundScope) + asked.refetch() + asking.hung = true + val answer = AidenQuestionRespondRequest(false, listOf(AidenQuestionAnswer.Option(0, "Blue"))) + givesUpInTime { asked.answerQuestion(answer) } + assertEquals(colourQuestion, asked.state.value.session?.question) + givesUpInTime { asked.answerQuestion(answer) } + assertEquals(1, asking.keys.getValue("answer").toSet().size) + givesUpInTime { asked.respondToApproval(AidenApprovalDecision.DENY) } + assertEquals(mailApproval, asked.state.value.session?.approval) + givesUpInTime { asked.respondToApproval(AidenApprovalDecision.DENY) } + assertEquals(1, asking.keys.getValue("approval").toSet().size) + + // A routine proposal goes back to pending. + val proposing = HangingTransport(FakeTransport(cardsSession)) + val proposer = AidenBotSessionController(cardsSession.botId, proposing, backgroundScope) + proposer.refetch() + proposing.hung = true + givesUpInTime { proposer.respondToProposal(pendingProposalId, AidenBotRoutineProposalDecision.ACCEPT) } + assertEquals(AidenBotRoutineProposalStatus.PENDING, proposer.state.value.proposal(pendingProposalId).status) + givesUpInTime { proposer.respondToProposal(pendingProposalId, AidenBotRoutineProposalDecision.ACCEPT) } + assertEquals(1, proposing.keys.getValue("proposal").toSet().size) + } + + // FR-m2: the Mac's answer stands unless the Mac wrote the state since the tap. + @Test + fun theMacsAnswerAppliesUnlessTheMacWroteTheStateSinceTheTap() = runTest { + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, activitySupported = true) + controller.refetch() + + // Stop: an unrelated frame arrives before the answer, which still stands. + transport.stopGate = CompletableDeferred() + val stopping = async { controller.stop() } + runCurrent() + controller.frame(AidenBotSessionEventPayload.Partial("Here is")) + transport.stopGate!!.complete(Unit) + assertTrue(stopping.await()) + assertEquals(AidenBotSessionState.IDLE, controller.state.value.state) + + // A send: the receipt's state stands after an entry frame, not after a state frame. + transport.receiptState = AidenBotSessionState.RUNNING + transport.sendGate = CompletableDeferred() + val sending = async { controller.send("Plan lunch") } + runCurrent() + controller.frame(AidenBotSessionEventPayload.Entry(userEntry("entry_60", "Something else"))) + transport.sendGate!!.complete(Unit) + assertTrue(sending.await()) + assertEquals(AidenBotSessionState.RUNNING, controller.state.value.state) + + transport.stopState = AidenBotSessionState.RUNNING + transport.stopGate = CompletableDeferred() + val second = async { controller.stop() } + runCurrent() + controller.frame(AidenBotSessionEventPayload.State(AidenBotSessionStateView(AidenBotSessionState.IDLE, false))) + transport.stopGate!!.complete(Unit) + assertTrue(second.await()) + assertEquals("the Mac's newer state wins over the older answer", AidenBotSessionState.IDLE, controller.state.value.state) + } + + // FR-m5: a taken message nothing on the Mac shows is marked unheld and goes only once 10 s have passed. + @Test + fun aTakenMessageNothingOnTheMacShowsGoesOnlyAfterItsGrace() = runTest { + val transport = FakeTransport(runningSession) + transport.receiptIds += listOf("21", "22") + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, activitySupported = true) + controller.refetch() + var seq = runningSession.seq + suspend fun snapshot(queued: List = emptyList()) { + seq += 1 + controller.handle(snapshotOf(runningSession.copy(seq = seq, queued = queued))) + } + + assertTrue(controller.send("Plan lunch")) + snapshot() + snapshot() + assertEquals("a second look within the grace keeps it", listOf("Plan lunch"), controller.state.value.outgoing.map { it.text }) + advanceTimeBy(AidenBotSessionController.UNHELD_GRACE_MILLIS - 1) + runCurrent() + assertEquals(listOf("Plan lunch"), controller.state.value.outgoing.map { it.text }) + advanceTimeBy(2) + runCurrent() + assertTrue(controller.state.value.outbox.isEmpty()) + + // Showing up queued again clears the mark. + assertTrue(controller.send("And dinner")) + snapshot() + snapshot(listOf(AidenBotQueuedMessage("22", AidenStreamInputMode.QUEUE, "And dinner"))) + advanceTimeBy(AidenBotSessionController.UNHELD_GRACE_MILLIS + 1) + runCurrent() + assertEquals("22", controller.state.value.outbox.single().queuedId) + assertFalse(controller.state.value.outbox.single().unheld) + } + + // Controller polish: a frozen request refused for the Bot's state keeps its key for Try again. + @Test + fun aFrozenMessageTheBotCantTakeKeepsItsKey() = runTest { + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope) + controller.refetch() + transport.sendFailures += IOException("dropped") + assertTrue(controller.send("Plan lunch")) + controller.frame(AidenBotSessionEventPayload.State(AidenBotSessionStateView(AidenBotSessionState.NEEDS_MODEL, false))) + advanceTimeBy(1_001) + runCurrent() + val failed = controller.state.value.outbox.single() + assertEquals(AidenBotOutgoingStatus.FAILED, failed.status) + assertTrue("its first attempt may have arrived", failed.keepsKey) + } + + // FR-m4: the copy shared with iOS. + @Test + fun theOutboxNoticesUseTheCopySharedWithIos() { + val document = javax.xml.parsers.DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(java.io.File("src/main/res/values/strings.xml")) + val nodes = document.getElementsByTagName("string") + val strings = (0 until nodes.length).associate { index -> + val element = nodes.item(index) as org.w3c.dom.Element + element.getAttribute("name") to element.textContent + } + fun english(id: Int) = strings.getValue(sbtbiswas.AidenOnTheGo.R.string::class.java.fields.first { it.getInt(null) == id }.name) + assertEquals("That message wasn’t sent. Please try again.", english(AidenBotSessionNotice.ACCESS_LOST.text)) + assertEquals("Too many messages are waiting. Send or remove some, then try again.", english(AidenBotSessionNotice.OUTBOX_FULL.text)) + assertEquals("Some unsent messages couldn’t be restored.", english(AidenBotSessionNotice.OUTBOX_UNRESTORABLE.text)) + val r = sbtbiswas.AidenOnTheGo.R.string::class.java + fun named(name: String) = english(r.getField(name).getInt(null)) + assertEquals( + "Couldn’t send · Try again", + "${named("bot_session_send_failed")} ${named("bot_session_send_failed_separator")} ${named("bot_session_try_again")}" + ) + } + + // Chat polish: a first read the Mac refuses says so, and Try again shows at once that it's trying. + @Test + fun aRefusedFirstReadSaysAccessAndTryAgainShowsItIsTrying() = runTest { + val refused = FakeTransport(runningSession).apply { sessionFailures += serverError(403, AidenRemoteErrorCode.AUTHENTICATION_REQUIRED) } + val locked = AidenBotSessionController(runningSession.botId, refused, backgroundScope) + assertFalse(locked.refetch()) + assertEquals(AidenBotConnectionHint.NO_ACCESS, aidenBotConnectionHint(locked.state.value, started)) + + val hanging = HangingTransport(FakeTransport(runningSession)) + hanging.hung = true + val controller = AidenBotSessionController(runningSession.botId, hanging, backgroundScope) + val first = async { controller.refetch() } + advanceTimeBy(AidenBotSessionController.REQUEST_LIMIT_MILLIS + 1) + runCurrent() + assertFalse(first.await()) + assertEquals(AidenBotConnectionHint.UNREACHABLE, aidenBotConnectionHint(controller.state.value, started)) + assertEquals(AidenBotSkeletonLook.STILL, aidenBotSkeletonLook(controller.state.value)) + val again = async { controller.refetch() } + runCurrent() + assertEquals("trying again", AidenBotConnectionHint.CONNECTING, aidenBotConnectionHint(controller.state.value, started)) + assertEquals(AidenBotSkeletonLook.SHIMMERING, aidenBotSkeletonLook(controller.state.value)) + hanging.hung = false + again.cancel() + } } diff --git a/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index cd7160cc5..0e6f22dfc 100644 --- a/docs/aiden-remote-api-v1.md +++ b/docs/aiden-remote-api-v1.md @@ -46,6 +46,7 @@ Initial capability IDs: | `schedule:write` | Confirmed task/settings mutations and run-now. | | `bot:read` | Read Bot identity and Bot-classified chats, streams, attachments, and approvals. Granted only after explicit client negotiation. | | `bot:write` | Mutate Bot-classified chats and operate their turns, attachments, streams, and approvals. Requires `bot:read` as well as the existing route capability. | +| `bot:activity` | Opt-in, no authority (contract revision 30). Delivers the Bot session `activity` and `queued` frames and snapshot keys, the person's `submissionId` on their own user entries, and Bot detail `chatActivity`. Negotiable post-pairing by any device type through `POST /device/capabilities` while `/server.features` lists `bot-activity-v1` (`404 not_found` otherwise), advertised in `serverCapabilities` only then, never issued by pairing. See "Bot chat activity, revision 30". | | `tasks:read` | Read the bounded chat task-progress projection and receive `task_update` events. Granted only after explicit client negotiation at pairing or post-pairing. | | `agents:read` | Read the bounded delegated-agent roster projection and receive `agents_update` events. Granted only after explicit client negotiation at pairing or post-pairing. | | `questions:respond` | Resolve `ask_user_question` prompts owned by this device/stream and receive `question_required` events. Granted only after explicit client negotiation at pairing or post-pairing. | @@ -628,3 +629,31 @@ Aiden draws inline visuals in desktop replies: native visuals from its component After a reply commits, the Mac renders each visual offscreen and stores the image as an ordinary PNG or JPEG attachment on the same message. Its `snapshotAttachmentId` appears once that attachment exists, which changes the chat `revision`, so clients that refetch on a revision change pick it up. Snapshot bytes are read through the existing attachment content route; nothing new is fetched. Clients place each visual after the timeline tool step whose `toolCallId` matches, or at the end of the reply when no step matches. They show the snapshot image when one is named, otherwise `fallbackText`, otherwise the title alone, and never show snapshot attachments a second time in the attachment list. Older clients that ignore `visuals` see the snapshots as ordinary image attachments. Phase 3 projects no markup; interactive native rendering on phones is a later, opt-in revision. + +## Bot chat activity, revision 30 (`bot-activity-v1`, provisional) + +Revision 30 is provisional and is re-claimed at merge, after whatever `main` holds then. + +Phones show what a Bot's running turn is doing ("Reading", "Running a command"), the person's own waiting messages, and the Bot's Chat activity choice. The Mac projects only a coarse phase: never a step, detail, path, command, query, argument or thinking text. + +**Discovery and negotiation.** A host lists `bot-activity-v1` in `GET /server.features` only while the session `activity` and `queued` frames, `whenBusy`, and `chatActivity` on Bot detail and `PATCH` are all wired together. Older phones refuse unknown session keys and event types, so the token alone does not gate what the host sends. A device sends `POST /device/capabilities {accepts: ["bot:activity"]}` (offered in `serverCapabilities` to Bot-aware devices while the token is announced, like revision 27's `bot:cards`). The capability carries no authority and any device type may hold it; a host without the feature answers `404 not_found`. Pairing never issues it. A Mac older than revision 30 omits the token and refuses the negotiation, so phones keep today's behaviour and hide the Chat activity row. + +**Session stream** (`GET /bots/{botId}/session` and `/session/events`, for devices holding `bot:activity`). +- The `snapshot` gains `activity`: `{ phase, app?, startedAt }` or `null`, and `queued`: a list. +- Two new events carry the same shapes: `activity` with payload `{ activity: {…} | null }` and `queued` with payload `{ queued: […] }`. Frames keep the one-seq-after-the-last rule and are published after `state`, then `queued`. +- `activity` has exactly the keys `phase`, `app` and `startedAt`. `phase` matches `^[a-z][a-z_]{0,31}$`; a client that does not know the id renders `working`. `app` is present only for the `app` phase, the connection's display label cut to 40 Unicode code points. `startedAt` is the Mac's clock in milliseconds since 1970, an integer from 0 to 2^53-1; clients show elapsed time after 20 seconds and hide it when their own clock reads behind the Mac's. It is when the run started, not when this stream or projector opened: a device that opens mid-run, or reconnects after the Mac closed an idle projector, gets the same `startedAt` as everyone else (a run already going when Aiden itself started is timed from when the Mac first saw it). The words and marks each phase maps to are pinned by the `botActivity.phases` fixture; the Mac's `renderer/shared/bot-activity.ts` is the source. +- `queued` lists the person's own waiting messages: `{ submissionId, mode: "steer" | "queue", text }`, at most 20, unique ids, text at most 32,000 code points. `steer` joins the running turn; `queue` runs after it. Routine and hidden inputs never appear, and a message that is only a photo has no text and is omitted. There is no remove route in this revision. +- **Byte caps.** Every frame is bounded on its own below the 1 MiB SSE frame limit: a `partial` or entry text is at most 100,000 code points (600,000 JSON bytes with every character escaped), a `question` at most four questions of five options (about 266 KB), a `queued` list at most 256 KiB, and an `approval` about 14 KB. A snapshot's serialized `session` is kept under the frame limit less a 64 KiB margin for the SSE lines and envelope, by allocating that budget in this order: (1) identity, `state`, `question`, `approval` and `activity` ship whole (at their maxima they take about 280 KB); (2) the `partial` ships whole, or, if it no longer fits, is cut from its front to its newest words, on a code-point boundary, which phones accept because a `partial` replaces the previous text wholesale and the next partial frame replaces it again; (3) `queued` takes at most 256 KiB and what is left, oldest first, and the first message that would overflow ends the list, so the rest are omitted and no text is ever cut (phones keep their own outbox copies); (4) the transcript window takes what is left, never more than 768 KiB, keeping the newest entries that fit, so it can be empty. When older entries are left out, `hasOlder` is `true`. +- A user `message` entry may carry `submissionId` (pattern `^[A-Za-z0-9._:-]{1,64}$`): the id the same message had in its send receipt and in `queued`, so a phone settles its optimistic bubble exactly. It is never on a routine input (an entry with `label`) or a question answer, and it is omitted when the Mac no longer knows the id. The tag stays on an entry while the entry is in the projected view. +- Stop and Resume reach the projection the same way as on the Mac: a phone Stop reads "Stopping" (and clears if the Stop fails), and a Resume reads "Picking up where it left off". +- **Non-holders.** A device without `bot:activity` receives a no-op `state` frame (the projector's current state view) at every seq an `activity` or `queued` frame would use, and no `activity`, `queued` or `submissionId` anywhere, so its decoder never sees a new key or event and its seq chain stays unbroken. + +**Sending while busy.** `POST /bots/{botId}/messages` accepts the optional `whenBusy: "steer" | "queue"` on hosts announcing the token (the runtime maps `queue` to a follow-up). Without it the message queues, as before. A host that does not announce the token rejects the unknown field with `400 invalid_request`, so phones send it only to hosts that do. The receipt is unchanged, and carries the `submissionId` the entry and queued list later reuse. Idempotency is per key: replaying a request must send the same body, including `whenBusy`, or the host answers `409 idempotency_conflict`. A send, Resume, Dismiss or Stop that the Mac refuses from the Bot's state before doing anything (`409 operation_stale` for no model, a model that can't run right now, Bots held by another Aiden process or a paused turn) records nothing for its key, so the same key is evaluated again once the Bot is fixed. Any other outcome is recorded and replayed for that key. + +**Chat activity setting.** +- `GET /bots/{botId}` detail gains `chatActivity: { level: "quiet" | "summary" | "detailed", wording: "follows" | "thinking" }` for devices holding `bot:activity`. A Bot that never chose has the default Quiet and Follows. Every route that returns a Bot detail (create, get, patch and avatar DELETE) strips the field for other devices; avatar PUT returns the asset metadata, not a detail. +- `PATCH /bots/{botId}` accepts `chatActivity` with exactly those two keys and known values, next to the other identity fields and under `If-Match`. An update that leaves it out keeps the stored value. It is rejected as an unknown field on hosts that do not announce the token. Choosing a level changes the Bot revision, so a phone's next identity edit may get `revision_conflict` and must refresh. + +**Phone behaviour (not wire).** Phones apply every Bot action at once and reconcile later, keep unsent messages in a local per-Bot outbox for up to 24 hours (file-protected, excluded from backup, purged on unpair and access loss), and bound each Bot request to 12 seconds. Those are client rules recorded in `.memory/bot-chat-activity.md`; the host sees only ordinary idempotent requests. + +**Fixtures.** `protocol/aiden-remote/v1/fixtures/contract.json` holds `botActivity` (sessions, frames, queued bounds, accepted and rejected samples, `phases`, Chat activity and detail samples) and `botActivityPrivacy` (a frame never carries a key outside the allow-list; `rejectedNullLiterals` pins that an optional key sent as the literal `null`, such as `app`, `queued` or `label`, is refused rather than read as absent). TypeScript, Swift and Kotlin decode them. diff --git a/docs/plans/README.md b/docs/plans/README.md index b8f85e728..d67fd8626 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -13,7 +13,7 @@ Last refreshed 2026-10-01 against `main` at `d2197dfef` (0.51.0 plus later merge - [Bot memory, soul and proactivity](2026-10-09-bot-memory-soul-proactivity.md) — Merged in [PR #433](https://github.com/sambitcreate/aiden-agent/pull/433) after PR #428; ships in 0.56.0. Spec: [design](../superpowers/specs/2026-10-09-bot-memory-soul-proactivity-design.md). Hermes-style bounded per-Bot memory (`MEMORY.md`/`USER.md` under `/bots//memory/`, main-owned `bot_memory` tool, frozen prompt snapshot, compaction flush and steered summary, add-only background review every 10 person turns) with Profile → Memory on desktop, iOS and Android; routine results to phones and Telegram, catch-up after pause, approve/Not now routine proposals, per-routine notes and a daily check-in suggestion; `openingGreeting` removed. Persona stays in the Bot record (no `SOUL.md` file). Three parallel tracks (A memory core, B proactivity + Remote, C clients); Remote contract revision 27 is provisional and is claimed at merge (`bot-memory-v1`, `bot-proactive-v1`, device capability `bot:cards`). -- [Bot chat activity, Bots sidebar and side panel](../superpowers/specs/2026-10-14-bot-chat-activity-design.md) — Active (2026-10-14). Five stacked slices: A quick fixes ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-a-quick-fixes.md): model choice in New Bot on desktop/iOS/Android, Advanced and Memory in every ••• menu, Advanced model control fix; [PR #475](https://github.com/sambitcreate/aiden-agent/pull/475)), B quiet status line + per-Bot Chat activity setting ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-b-status-line.md); [PR #477](https://github.com/sambitcreate/aiden-agent/pull/477), stacked on #475), C1 workbench shell extraction ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-c1-workbench-shell.md); [PR #476](https://github.com/sambitcreate/aiden-agent/pull/476), stacked on B), C2 Bots-only sidebar + Bot side panel ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-c2-sidebar-panel.md); implemented, stacked on C1), D phones (Remote revision 30, `bot-activity-v1`). Plans are written before each slice starts. +- [Bot chat activity, Bots sidebar and side panel](../superpowers/specs/2026-10-14-bot-chat-activity-design.md) — Active (2026-10-14). Five stacked slices: A quick fixes ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-a-quick-fixes.md): model choice in New Bot on desktop/iOS/Android, Advanced and Memory in every ••• menu, Advanced model control fix; [PR #475](https://github.com/sambitcreate/aiden-agent/pull/475)), B quiet status line + per-Bot Chat activity setting ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-b-status-line.md); [PR #477](https://github.com/sambitcreate/aiden-agent/pull/477), stacked on #475), C1 workbench shell extraction ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-c1-workbench-shell.md); [PR #476](https://github.com/sambitcreate/aiden-agent/pull/476), stacked on B), C2 Bots-only sidebar + Bot side panel ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-c2-sidebar-panel.md); implemented, stacked on C1), D phones ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-d-phones.md): Remote revision 30 provisional, `bot-activity-v1` + negotiable `bot:activity`; implemented on iOS, Android and the Mac host, with review and final-wave polish still open, listed in spec §17 and `.memory/bot-chat-activity.md`; revision 30 is re-claimed at merge). Plans are written before each slice starts. - [Bots rework (Grok-style Bots + Pi Durable sessions)](../superpowers/specs/2026-10-07-bots-rework-design.md) — Implemented on [PR #377](https://github.com/sambitcreate/aiden-agent/pull/377); physical-device acceptance pending. Plan: [implementation plan](../superpowers/plans/2026-10-07-bots-rework.md). Bots are single-thread chats on desktop, iOS and Android, each with one Pi Durable session on `@earendil-works/pi-durable` 1.0.3 (`/bots//session.sqlite`) and explicit Resume/Dismiss after an interruption. Also shipped: per-Bot routines, starter presets, consent-first connection cards and a permanent Delete that replaces Archive. Aiden Remote is at contract revision 25 (`bot-delete-v1`, `bot-durable-session-v1`, `bot-routines-v1`, `bot-connection-requests-v1`, `bot-presets-v1`). Known follow-ups are listed in `.memory/bots-rework-ui.md`. diff --git a/docs/superpowers/plans/2026-10-14-bot-chat-activity-d-phones.md b/docs/superpowers/plans/2026-10-14-bot-chat-activity-d-phones.md new file mode 100644 index 000000000..fbaec6311 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-bot-chat-activity-d-phones.md @@ -0,0 +1,5355 @@ +# Bot chat activity · Slice D (phones) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** While a Bot works, iPhone and Android show the same calm status line as the Mac ("Getting ready", "Reading", "Using Notion", "Reconnecting to your Mac"). People can Steer or Queue while it works, queued messages show faded with "Queued", a working Bot's face glances in Bots home, and each Bot's Chat activity choice can be changed from a native page. All of this runs over Aiden Remote contract revision 30 behind `bot-activity-v1`. + +**Architecture:** The Remote session projector (`aiden-remote-bot-session.ts`) derives each running turn's phase with slice B's pure `liveBotActivity` over its own conversation watch. It cuts the result to the §11 allow-list `{ phase, app?, startedAt }` and publishes it, with the person's waiting inputs (B's `liveQueuedMessages`), as two new SSE frames under the existing one-seq-after-the-last rule. A device opts into those frames, and into Bot detail `chatActivity`, with the negotiable `bot:activity` (the same pattern as revision 27's `bot:cards`). Every other device gets a no-op `state` frame at that seq. Stop and Resume go through one small process-wide emitter (`botTurnIntents`), so a Stop pressed on the Mac or on a phone reads "Stopping" on both. iOS and Android each keep one phase table, checked against a shared fixture. Each derives a pure status-line value from its session model and draws it with the native activity marks. The Chat activity page saves through `PATCH /bots/{id}` on every pick. + +**Tech Stack:** Electron main (Node, `@earendil-works/pi-durable` harness, `node:test` via `tsx --test`, pi-ai faux provider). iOS 18 SwiftUI with XCTest. Android Kotlin, Jetpack Compose Material 3 Expressive (the app's `ui/theme` layer), kotlinx.serialization, JUnit 4 and kotlinx-coroutines-test. + +**Spec:** [`docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md`](../specs/2026-10-14-bot-chat-activity-design.md). Slice D covers §2 decisions 3, 4 and 11, the §4 D row, §5 (words, §5.1 and §5.2), §7 (the Chat activity page on phones), §11, §12, §14 and the iOS/Android rows of §16. + +**Stack:** A → B → C1 → C2 → D. This plan **consumes slice B's names exactly** ([plan](2026-10-14-bot-chat-activity-b-status-line.md), rulings in `.superpowers/sdd/notes/b-plan-rulings.md`). From `renderer/shared/bot-activity.ts`: `BOT_ACTIVITY_PHASES`, `botActivityDisplay(phase, wording, app?)` and `botElapsedLabel(startedAt, now)`. From `renderer/shared/bot-live.ts`: `BotLiveActivity`. From `main/services/bot-runtime/live-projection.ts`: `liveBotActivity(input: BotLiveActivityInput)` and `liveQueuedMessages(docs)`. From `BotLiveProjection`: `notifyStopping` and `notifyResuming`. From `renderer/shared/bots.ts`: `BOT_CHAT_ACTIVITY_LEVELS`, `BOT_CHAT_ACTIVITY_WORDINGS`, `BotChatActivityLevel`, `BotChatActivityWording`, `BotChatActivity` and `resolveBotChatActivity`, plus `BotDefinition.chatActivity` and `BotUpdateInput.chatActivity` (absent keeps the stored value). D has no code dependency on C1/C2. + +## Findings this plan rests on + +- **Older phones are strict.** iOS `AidenBotSession` and `AidenBotSessionEvent` call `requireOnlyKeys` and throw on an unknown event `type`. Android `AidenBotWireJson` has `ignoreUnknownKeys = false`, and it also decodes Bot detail with it. A token in `/server.features` therefore isn't enough: the host must know the device can parse the new frames. Revision 27 solved the same problem with the negotiable `bot:cards` and a no-op `state` frame at the same seq. D copies that design with `bot:activity`. +- **The Remote projector is not the desktop projection.** `BotLiveProjector` attaches its own `conversation.watch`, so it can call B's pure `liveBotActivity` with the same `entries` and `docs`. It cannot see the desktop feed's private `stopping`/`resuming` flags, which is why the intents emitter exists. +- **Phones only show a user message once its entry exists.** A queued input sits in `pi.inbox` until its turn starts. A Queued bubble therefore needs the inbox on the wire. +- **The iOS Xcode project does not use synchronized folders.** This plan adds **no new Swift files**, so it needs no `project.pbxproj` edits. All Swift goes into existing files. New Kotlin files need no registration. +- **Android unit tests run on the JVM without Robolectric.** The shared fixture is on the test classpath (`build.gradle.kts` adds `protocol/aiden-remote/v1/fixtures`). `R.string` ids exist. `File("src/main/res/values/strings.xml")` is readable, with `AidenProviderIconTest` reading `src/main/res/drawable` as precedent. +- **Feature-list bound.** iOS `AidenServer` refuses more than 32 features. A phone can receive at most 24 today, so 25 is fine. +- **Revision.** This branch and `origin/main` are both at **29** (`AIDEN_REMOTE_CONTRACT_REVISION`). D provisionally claims **30**. At merge, re-claim the next revision after `main`'s and update TS, iOS, Android and the fixtures together (AGENTS.md). + +## Rulings on spec ambiguities + +1. **Per-device opt-in.** The spec names only the token. D adds the negotiable `bot:activity` (no authority, any device type), offered only while the token is announced. Holding it changes what the host *sends*: the `activity`/`queued` frames, the snapshot `activity`/`queued` keys, and Bot detail `chatActivity`. The token alone gates what the host *accepts*: `whenBusy`, and `chatActivity` on `PATCH`. Phones show the new UI only when the token is announced **and** the grant is held. +2. **Queued on the wire.** §12's Queued bubble needs data the wire lacks. D adds a `queued` frame and a snapshot `queued` list `{ submissionId, mode: "steer" | "queue", text }`, with at most 20 items and text of at most 32,000 code points. It carries only the person's own inputs, because B's `liveQueuedMessages` leaves out routine and hidden inputs. The `activity` allow-list is unchanged. Phones get **no Remove**: §12 doesn't list it and there is no Remote withdraw route. It is recorded as a follow-up. +3. **Shared Stop/Resume intents.** `botTurnIntents` (`main/services/bot-runtime/bot-turn-intents-main.ts`) is announced by `main/handlers/bots.ts` (replacing B's direct `notifyStopping`/`notifyResuming` calls) and by the Remote Stop/Resume routes. The desktop live projection and every Remote projector listen to it. +4. **When the phone line shows.** It shows when the token and grant are present, the state is `running`, and one of these holds: + - The stream is down: "Reconnecting to your Mac". + - No partial text is showing: the activity's phase, or `preparing` before the first activity frame arrives (success criterion 1). + + Elapsed time is the host's `startedAt` against the phone's clock. It is nil under 20 s **and when negative** (clock skew). Phones do **not** show the 2-minute "Still going…" note: §12 lists the phone line's parts, and phones are "always minimal". +5. **Unknown phase ids** decode when they match `^[a-z][a-z_]{0,31}$` and render as `working` (§5.2). +6. **Steer/Queue on phones.** + - The pill defaults to Queue and resets to Queue when the turn ends, matching normal chats. + - Stop stays where it is today: in the iOS composer and in Android's top bar. + - Without the token, phones behave as they do today. +7. **Phone previews.** Each row's looping preview illustrates what that level shows **on the Mac** (the desktop page's script), because the phone's own chat is always minimal. Under Reduce Motion the preview shows its still frame. §12 says "pause", so phones have no Play control. +8. **Faces.** A canonical face's eyes follow the shared `glance` keyframes (docs/activity-marks.md). A photo avatar gets a small `glance` mark badge. Both hold their still pose under Reduce Motion. This replaces the green "working" dot in Bots home. +9. **Detail `chatActivity`** is always present for `bot:activity` holders, resolved with `resolveBotChatActivity` (absent on the record means quiet / follows). +10. **Android copy.** Phase words and page copy go in `strings.xml`. The elapsed label (`"1m 12s"`) stays English, like `AidenTurnElapsed` (the JVM-tested presentation helper exception in `.memory/android-localization.md`). + +## Global Constraints + +- Contract revision **30** (provisional; claim the next after `main`'s at merge). TS, iOS, Android and `protocol/aiden-remote/v1/fixtures/contract.json` change together. +- `bot-activity-v1` is announced, and `bot:activity` is offered and negotiable, only while **both** the session service has `turnIntents` (`supportsActivity`) and the Bot service has `chatActivity: true` (`supportsChatActivity`). +- Activity wire shape, exactly: `{ phase: string, app?: string, startedAt: number } | null`. + - `phase` matches `^[a-z][a-z_]{0,31}$`. + - `app` is 1–40 code points. + - `startedAt` is an integer from 0 to `Number.MAX_SAFE_INTEGER`. + - Nothing else crosses: no `step`, `detail`, path, command, query or thinking. +- Queued wire shape, exactly: `{ submissionId: /^[A-Za-z0-9._:-]{1,64}$/, mode: "steer" | "queue", text: 1–32,000 code points }`, at most 20, with unique ids. +- Frames keep the one-seq-after-the-last rule. A device without `bot:activity` gets `state` with its current state view at an `activity`/`queued` seq. +- `whenBusy: "steer" | "queue"` maps to `steer` / `followUp`. Without `whenBusy`, behaviour is today's queue. Hosts without the token reject the key (`invalid_request`). +- `PATCH /bots/{id}` `chatActivity` takes exact keys `level` (`quiet|summary|detailed`) and `wording` (`follows|thinking`). An absent `chatActivity` keeps the stored value (B ruling 2). +- Phase words, marks and ids are exactly §5. Always "Thinking" keeps the words of `waiting`, `stopping` and `reconnecting` only. +- Phones never show steps, recaps or the hover peek in a chat. +- Elapsed after 20 s reads "· 1m 12s". There is no Stop on the line. +- The status line's accessible name is the word. Announcements come at most once every 2 s. +- Copy, exact: "Queued", "Reconnecting to your Mac", "Chat activity", "How much to show", "What the status line says", "On iPhone and Android", "Always just the line, with the same words.", "Quiet", "Summary", "Detailed", "Recommended", "Follows the work", "Always “Thinking”", "Aiden couldn’t save that choice.". +- Motion: marks, faces and previews hold their t = 0 / still pose under Reduce Motion (system or in-app) and while inactive. +- iOS: no new Swift files and no `project.pbxproj` edits. Use the native `Form` for the Chat activity page. +- Android: use the `ui/theme` Expressive layer (`AidenConnectedColumn`, `AidenGroupCard`, `AidenMotion`, `aidenReduceMotion()`). Copy goes in `strings.xml`, following `.memory/android-localization.md`. +- Radio rows have no decorative borders. Selection uses the radio and the tonal or background fill. Keep focus and selection semantics. +- No new desktop test files. Every extended desktop test file is already registered, and `npm run test:ci-policy` must still pass. +- Tests are behavioural. No source greps, no class-string assertions, no tautologies. +- No network calls beyond the existing Remote routes. + +## Review Focus + +1. **A phone that never negotiated `bot:activity`, or that is paired with an older Mac.** Expected: its stream keeps parsing, every seq stays gapless, it never sees `activity`/`queued` keys, and it never sees `chatActivity` on Bot detail. Tests: Task 2, Step 1, test 2 (stream) and Task 3, Step 1, test 4 (detail). +2. **The session stream drops mid-turn (a Wi-Fi blip).** Expected: the line reads "Reconnecting to your Mac" and returns to the real phase on the next connection's snapshot. It never sticks. Tests: Task 5, Step 1, test 4 (iOS) and Task 9, Step 1, test 4 (Android). +3. **A newer Mac sends a phase this phone doesn't know** (`browsing`). Expected: "Working" with `quad-shuffle`, and no decode failure. Tests: Task 4, Step 1, test 1 (iOS, fixture `teleporting`) and Task 8, Step 1, test 1 (Android). +4. **Stop pressed when nothing is running** (a double-tap after the reply landed). Expected: nothing changes, and the next turn starts at "Getting ready", never "Stopping". Test: Task 2, Step 1, test 3. +5. **Saving a Chat activity choice fails** (revision conflict or offline). Expected: the radio returns to the saved choice, an error shows, and the next pick sends the current revision. Tests: Task 7, Step 1, test 2 (iOS) and Task 11, Step 1, test 2 (Android). + +--- + +## File map + +| File | Responsibility | +|---|---| +| Modify `main/services/aiden-remote-protocol.ts` | Revision 30, `bot-activity-v1`, `bot:activity`, activity/queued/chatActivity types and strict parsers, `whenBusy` and `chatActivity` request options, fixture section parser | +| Modify `protocol/aiden-remote/v1/fixtures/contract.json`, `protocol/aiden-remote/v1/openapi.json` | Shared `botActivity` and `botActivityPrivacy` fixtures, revision 30, OpenAPI schemas | +| Create `main/services/bot-runtime/bot-turn-intents.ts`, `bot-turn-intents-main.ts` | Process-wide Stop/Resume intents | +| Modify `main/services/aiden-remote-bot-session.ts` | Activity and queued frames, audiences, `whenBusy`, intents | +| Modify `main/services/bot-runtime/bot-session-main.ts`, `main/handlers/bots.ts` | Desktop projection listens to intents; IPC Stop/Resume announce | +| Modify `main/services/aiden-remote-bots.ts`, `aiden-remote-router.ts`, `aiden-remote-state.ts`, `aiden-remote-service-main.ts` | Detail `chatActivity`, PATCH, token, opt-in negotiation, per-device stripping, wiring | +| Modify `ios/AidenOnTheGo/Models/AidenBotSession.swift`, `Models/AidenBot.swift`, `Networking/AidenRemoteContract.swift`, `Networking/AidenRemoteClient.swift`, `Models/AidenInstallation.swift`, `Features/Remote/AidenRemoteCoordinator.swift` | iOS wire models and negotiation | +| Modify `ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift`, `AidenBotSessionChatView.swift`, `AidenBotSemanticAvatarView.swift`, `AidenBotGeneratedAvatarLifecycle.swift`, `AidenBotsHomeView.swift`, `AidenBotAdvancedView.swift` | Phase table, status line, Steer/Queue, Queued bubble, glancing faces, Chat activity page | +| Modify `ios/AidenOnTheGoTests/AidenBotSessionTests.swift`, `AidenRemotePhase0Tests.swift` | iOS tests (four new test classes in the existing file) | +| Modify `android/.../models/AidenBotSession.kt`, `models/AidenBot.kt`, `protocol/AidenRemoteProtocol.kt`, `models/AidenInstallation.kt`, `features/remote/AidenRemoteCoordinator.kt`, `networking/AidenRemoteClient.kt` | Android wire models and negotiation | +| Create `android/.../features/bots/AidenBotActivity.kt`, `features/bots/AidenBotChatActivityScreen.kt` | Phase table, elapsed, announcements, face glance, status line; Chat activity page | +| Modify `android/.../features/bots/AidenBotSessionController.kt`, `AidenBotSessionScreen.kt`, `AidenBotSemanticAvatarView.kt`, `AidenBotCanonicalAvatarView.kt`, `AidenBotsHomeScreen.kt`, `AidenBotAdvancedScreen.kt`, `AidenBotProfileScreen.kt`, `features/chat/AidenComposerView.kt`, `res/values/strings.xml` | Controller state, chat UI, faces, Advanced row | +| Create `android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt`, `.../features/bots/AidenBotChatActivityTest.kt`; modify `.../features/bots/AidenBotSessionControllerTest.kt` | Android tests | +| Modify the spec, `docs/plans/README.md`, `.memory/bot-chat-activity.md`, `.memory/bots-rework-ui.md` | Docs and memory | + +Paths under `android/...` mean `android/app/src/main/java/sbtbiswas/AidenOnTheGo/`. + +--- + +### Task 1: Revision 30 wire types, strict parsers and shared fixtures + +**Files:** +- Modify: `main/services/aiden-remote-protocol.ts` +- Modify: `protocol/aiden-remote/v1/fixtures/contract.json`, `protocol/aiden-remote/v1/openapi.json` +- Test: `main/services/aiden-remote-protocol.test.ts`; revision bumps in `main/services/aiden-remote-operation-contract.test.ts`, `main/services/aiden-remote-router.test.ts`, `ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift` + +**Interfaces:** +- Consumes: `BOT_ACTIVITY_PHASES`, `botActivityDisplay`, `botElapsedLabel` (B Task 1). `BOT_CHAT_ACTIVITY_LEVELS`, `BOT_CHAT_ACTIVITY_WORDINGS`, `BotChatActivityLevel`, `BotChatActivityWording` (B Task 4). +- Produces (all exported from `aiden-remote-protocol.ts`): + - `AIDEN_REMOTE_CONTRACT_REVISION = 30` + - `AIDEN_REMOTE_BOT_ACTIVITY_FEATURE = "bot-activity-v1"` + - `AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES = ["bot:activity"]` and `AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES` (cards + activity) + - `AIDEN_REMOTE_BOT_ACTIVITY_APP_MAX_CHARS = 40`, `AIDEN_REMOTE_BOT_QUEUED_MAX = 20`, `AIDEN_REMOTE_BOT_WHEN_BUSY_MODES = ["steer", "queue"]`, and the type `AidenRemoteBotWhenBusy` + - `interface AidenRemoteBotSessionActivity { phase: string; app?: string; startedAt: number }` + - `interface AidenRemoteBotQueuedMessage { submissionId: string; mode: AidenRemoteBotWhenBusy; text: string }` + - `interface AidenRemoteBotChatActivity { level: BotChatActivityLevel; wording: BotChatActivityWording }` + - `AidenRemoteBotSession.activity?: AidenRemoteBotSessionActivity | null` and `AidenRemoteBotSession.queued?: AidenRemoteBotQueuedMessage[]` + - The event payloads `{ type: "activity"; payload: { activity: … | null } }` and `{ type: "queued"; payload: { queued: … } }` + - `AidenRemoteBotDetail.chatActivity?`, `AidenRemoteBotIdentityPatchRequest.chatActivity?` and `AidenRemoteBotMessageRequest.whenBusy?` + - `parseAidenRemoteBotSessionActivity(value)`, `parseAidenRemoteBotQueuedMessage(value)`, `parseAidenRemoteBotChatActivity(value)` + - `parseAidenRemoteBotMessageRequest(value, { allowWhenBusy?: boolean })` and `parseAidenRemoteBotIdentityPatchRequest(value, { allowChatActivity?: boolean })`, both defaulting to refusal + - `AidenRemoteContractFixture.botActivity?: AidenRemoteBotActivityFixture` + +- [ ] **Step 1: Write the failing tests** + +In `main/services/aiden-remote-protocol.test.ts`, extend the `./aiden-remote-protocol.js` import with `parseAidenRemoteBotSessionActivity`, `parseAidenRemoteBotDetail`, `parseAidenRemoteBotIdentityPatchRequest`, `parseAidenRemoteBotMessageRequest` and `parseAidenRemoteBotSession` (keep the existing names). Add: + +```ts +import { BOT_ACTIVITY_PHASES, botActivityDisplay, botElapsedLabel } from "../../renderer/shared/bot-activity.js"; +``` + +Then append: + +```ts +// --------------------------------------------------------------------------- +// Contract revision 30: Bot chat activity on phones +// --------------------------------------------------------------------------- + +test("Bot activity frames carry only the §11 allow-list, and every privacy sample is refused", async () => { + const raw = (await json("fixtures/contract.json")) as Record; + const fixture = parseAidenRemoteContractFixture(raw); + const section = fixture.botActivity!; + assert.deepEqual(section.session, raw.botActivity.session, "the activity session round-trips"); + assert.deepEqual(section.events, raw.botActivity.events, "every activity frame round-trips"); + const shown = [section.session.activity, ...section.events.flatMap((event) => (event.type === "activity" ? [event.payload.activity] : []))]; + for (const activity of shown) { + if (activity === null || activity === undefined) continue; + assert.deepEqual(Object.keys(activity).filter((key) => !["phase", "app", "startedAt"].includes(key)), []); + } + for (const sample of raw.botActivityPrivacy.rejectedActivities) { + assert.throws(() => parseAidenRemoteBotSessionActivity(sample), undefined, JSON.stringify(sample)); + const frame = { ...raw.botActivity.events[0], payload: { activity: sample } }; + assert.throws(() => parseAidenRemoteBotSessionEvent(frame), undefined, JSON.stringify(sample)); + } + for (const sample of raw.botActivityPrivacy.rejectedQueued) { + assert.throws(() => parseAidenRemoteBotSession({ ...raw.botActivity.session, queued: [sample] }), undefined, JSON.stringify(sample)); + } + for (const sample of raw.botActivityPrivacy.rejectedChatActivity) { + assert.throws(() => parseAidenRemoteBotDetail({ ...raw.botActivity.detail, chatActivity: sample }), undefined, JSON.stringify(sample)); + assert.throws(() => parseAidenRemoteBotIdentityPatchRequest({ chatActivity: sample }, { allowChatActivity: true })); + } + const twentyOne = Array.from({ length: 21 }, (_, index) => ({ submissionId: String(index), mode: "queue", text: "x" })); + assert.throws(() => parseAidenRemoteBotSession({ ...raw.botActivity.session, queued: twentyOne }), /at most 20/u); +}); + +test("whenBusy and chatActivity are refused unless the host wires bot-activity-v1", async () => { + const raw = (await json("fixtures/contract.json")) as Record; + const send = raw.botActivity.send.request; + assert.throws(() => parseAidenRemoteBotMessageRequest(send), /unsupported field whenBusy/u); + assert.deepEqual(parseAidenRemoteBotMessageRequest(send, { allowWhenBusy: true }), send); + assert.deepEqual(parseAidenRemoteBotMessageRequest({ text: "Hi" }, { allowWhenBusy: true }), { text: "Hi" }); + assert.throws(() => parseAidenRemoteBotMessageRequest({ text: "Hi", whenBusy: "followUp" }, { allowWhenBusy: true })); + const patch = raw.botActivity.identityPatch.request; + assert.throws(() => parseAidenRemoteBotIdentityPatchRequest(patch), /unsupported field chatActivity/u); + assert.deepEqual(parseAidenRemoteBotIdentityPatchRequest(patch, { allowChatActivity: true }), patch); +}); + +test("the shared phase table is the one the Mac draws, and an unknown phase reads as Working", async () => { + const raw = (await json("fixtures/contract.json")) as Record; + const { phases, thinkingKeeps, app, elapsed } = raw.botActivity as { + phases: Array<{ id: string; word: string; mark: string }>; + thinkingKeeps: string[]; + app: { app: string; word: string }; + elapsed: Array<{ startedAt: number; now: number; label: string | null }>; + }; + assert.deepEqual(phases.map((phase) => phase.id), [...BOT_ACTIVITY_PHASES]); + for (const phase of phases) { + assert.deepEqual(botActivityDisplay(phase.id, "follows"), { phase: phase.id, word: phase.word, mark: phase.mark }); + assert.equal(botActivityDisplay(phase.id, "thinking").phase, thinkingKeeps.includes(phase.id) ? phase.id : "thinking"); + } + assert.equal(botActivityDisplay("app", "follows", app.app).word, app.word); + assert.deepEqual(botActivityDisplay("teleporting", "follows"), botActivityDisplay("working", "follows")); + for (const row of elapsed) assert.equal(botElapsedLabel(row.startedAt, row.now), row.label, JSON.stringify(row)); +}); +``` + +Update the existing assertions the revision changes: +- `aiden-remote-protocol.test.ts:140`: `assert.equal(fixture.contractRevision, 29);` → `30`. +- In the same file's pairing-requests test, under `delete revision19.chatFork;`, add: + ```ts + // Bot chat activity is revision 30. + delete revision19.botActivity; + delete revision19.botActivityPrivacy; + ``` +- `aiden-remote-protocol.test.ts:1127`: the `BotIdentityPatch properties` list becomes `["name", "purpose", "openingGreeting", "instructions", "avatar", "chatActivity"]`. +- `aiden-remote-operation-contract.test.ts:900`: `29` → `30`. +- `aiden-remote-router.test.ts:4190`: `contractRevision: 29,` → `contractRevision: 30,`. +- `ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift:139`: `XCTAssertEqual(fixture.contractRevision, 29)` → `30`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx tsx --test main/services/aiden-remote-protocol.test.ts` +Expected: FAIL. The new parsers are not exported, `fixture.botActivity` is undefined, and the revision is 29. + +- [ ] **Step 3: Constants, capabilities and the revision** + +In `main/services/aiden-remote-protocol.ts`: + +1. Add to the imports: + ```ts + import { + BOT_CHAT_ACTIVITY_LEVELS, + BOT_CHAT_ACTIVITY_WORDINGS, + type BotChatActivityLevel, + type BotChatActivityWording, + } from "../../renderer/shared/bots.js"; + ``` +2. Replace the revision block: + ```ts + * Contract revision 29: completed `ask_user_question` tool steps on a version 3 + * generation timeline may carry a display-only `questionnaire` recap of the + * questions and what the owner chose; older clients ignore it. + * + * Contract revision 30 (provisional; re-claim the next revision after `main`'s + * at merge): Bot chat activity on phones (`bot-activity-v1`, `bot:activity`). + */ + export const AIDEN_REMOTE_CONTRACT_REVISION = 30 as const; + ``` +3. Below `AIDEN_REMOTE_BOT_PROACTIVE_FEATURE`: + ```ts + /** + * Bot chat activity (contract revision 30). Announced only while the host + * projects the session `activity` and `queued` frames, accepts `whenBusy` on + * `POST /bots/{botId}/messages`, and serves `chatActivity` on Bot detail and + * `PATCH /bots/{botId}`. A device opts into the frames and the detail field + * with the negotiable `bot:activity`. + */ + export const AIDEN_REMOTE_BOT_ACTIVITY_FEATURE = "bot-activity-v1" as const; + ``` +4. Below `export type AidenRemoteBotCardCapability = …;`: + ```ts + /** + * Contract revision 30: a device holding `bot:activity` receives the session + * `activity` and `queued` frames and Bot detail `chatActivity`. Like + * `bot:cards` it grants no authority; any device type may hold it. + */ + export const AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES = ["bot:activity"] as const; + + /** Opt-ins that change what a device is sent, never what it may do. */ + export const AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES = [ + ...AIDEN_REMOTE_BOT_CARD_CAPABILITIES, + ...AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES, + ] as const; + ``` + Then add `...AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES,` as the last element of both `AIDEN_REMOTE_NEGOTIABLE_CAPABILITIES` and `AIDEN_REMOTE_CAPABILITIES`. +5. In `AIDEN_REMOTE_BOT_SESSION_EVENT_TYPES`, insert `"activity",` and `"queued",` between `"approval",` and `"closed",`. + +- [ ] **Step 4: Types** + +1. Add to `AidenRemoteBotDetail` (inside the `&` object, after `visionModelSelection?`): + ```ts + /** Contract revision 30, only for devices holding `bot:activity`. */ + chatActivity?: AidenRemoteBotChatActivity; + ``` +2. Add to `AidenRemoteBotIdentityPatchRequest`: + ```ts + /** Contract revision 30, only on hosts announcing `bot-activity-v1`. Absent keeps the saved choice. */ + chatActivity?: AidenRemoteBotChatActivity; + ``` +3. Directly above `/** State of a Bot's one durable conversation. */`, add: + ```ts + // --- Contract revision 30: Bot chat activity. + + export const AIDEN_REMOTE_BOT_ACTIVITY_APP_MAX_CHARS = 40; + export const AIDEN_REMOTE_BOT_QUEUED_MAX = 20; + export const AIDEN_REMOTE_BOT_WHEN_BUSY_MODES = ["steer", "queue"] as const; + export type AidenRemoteBotWhenBusy = (typeof AIDEN_REMOTE_BOT_WHEN_BUSY_MODES)[number]; + const AIDEN_REMOTE_BOT_ACTIVITY_PHASE = /^[a-z][a-z_]{0,31}$/u; + const AIDEN_REMOTE_BOT_SUBMISSION_ID = /^[A-Za-z0-9._:-]{1,64}$/u; + + /** What a Bot in a running turn is doing (spec §5). Exactly these keys cross the wire (§11). */ + export interface AidenRemoteBotSessionActivity { + /** A §5 phase id. Clients render an id they do not know as `working`. */ + phase: string; + /** The connection's label for `app`, at most 40 code points. */ + app?: string; + /** When the turn started: the Mac's clock, milliseconds since 1970. */ + startedAt: number; + } + + /** A message waiting for the running turn: `steer` joins it, `queue` runs after it. */ + export interface AidenRemoteBotQueuedMessage { + submissionId: string; + mode: AidenRemoteBotWhenBusy; + text: string; + } + + /** A Bot's Chat activity choice (spec §7). */ + export interface AidenRemoteBotChatActivity { + level: BotChatActivityLevel; + wording: BotChatActivityWording; + } + ``` +4. Add to `AidenRemoteBotSession`, after `approval`: + ```ts + /** Revision 30, only for `bot:activity` devices: what the running turn is doing, or null. */ + activity?: AidenRemoteBotSessionActivity | null; + /** Revision 30, only for `bot:activity` devices: the person's messages waiting for the turn. */ + queued?: AidenRemoteBotQueuedMessage[]; + ``` +5. Add to `AidenRemoteBotSessionEventPayload`, before the `closed` member: + ```ts + | { type: "activity"; payload: { activity: AidenRemoteBotSessionActivity | null } } + | { type: "queued"; payload: { queued: AidenRemoteBotQueuedMessage[] } } + ``` +6. Replace `AidenRemoteBotMessageRequest`: + ```ts + export interface AidenRemoteBotMessageRequest { + text: string; + /** Revision 30, only on hosts announcing `bot-activity-v1`. Absent queues, as before. */ + whenBusy?: AidenRemoteBotWhenBusy; + } + ``` + +- [ ] **Step 5: Parsers** + +1. Directly above `export function parseAidenRemoteBotIdentityPatchRequest(`, add: + ```ts + /** A Chat activity choice: exact keys, known values. */ + export function parseAidenRemoteBotChatActivity(value: unknown): AidenRemoteBotChatActivity { + if (!isRecord(value)) throw new Error("Bot chat activity must be an object."); + assertExactKeys(value, ["level", "wording"], "Bot chat activity"); + return { + level: enumMember(value.level, BOT_CHAT_ACTIVITY_LEVELS, "Bot chat activity level"), + wording: enumMember(value.wording, BOT_CHAT_ACTIVITY_WORDINGS, "Bot chat activity wording"), + }; + } + ``` +2. Replace `parseAidenRemoteBotIdentityPatchRequest`: + ```ts + export function parseAidenRemoteBotIdentityPatchRequest( + value: unknown, + options: { allowChatActivity?: boolean } = {}, + ): AidenRemoteBotIdentityPatchRequest { + if (!isRecord(value)) throw new Error("Bot identity patch must be an object."); + const allowed = [ + "name", + "purpose", + ...AIDEN_REMOTE_IGNORED_BOT_IDENTITY_FIELDS, + "instructions", + "avatar", + // Revision 30: older hosts reject the key, so phones send it only where it is announced. + ...(options.allowChatActivity === true ? ["chatActivity"] : []), + ]; + assertExactKeys(value, allowed, "Bot identity patch"); + if (Object.keys(value).length === 0) { + throw new Error("Bot identity patch must change at least one field."); + } + // A patch carrying only the retired greeting is a valid no-op. + ignoreRetiredBotGreeting(value, "Bot identity"); + return { + ...(hasOwn(value, "name") + ? { name: boundedText(value.name, "Bot identity name", 80) } + : {}), + ...(hasOwn(value, "purpose") + ? { purpose: boundedText(value.purpose, "Bot identity purpose", 280, true) } + : {}), + ...(hasOwn(value, "instructions") + ? { instructions: boundedText(value.instructions, "Bot identity instructions", 32_000) } + : {}), + ...(hasOwn(value, "avatar") + ? { avatar: parseBotSemanticAvatar(value.avatar, true) } + : {}), + ...(hasOwn(value, "chatActivity") + ? { chatActivity: parseAidenRemoteBotChatActivity(value.chatActivity) } + : {}), + }; + } + ``` +3. In `parseAidenRemoteBotDetail`, after the `visionModelSelection` spread, add: + ```ts + ...(hasOwn(value, "chatActivity") + ? { chatActivity: parseAidenRemoteBotChatActivity(value.chatActivity) } + : {}), + ``` +4. Directly above `export function parseAidenRemoteBotSession(`, add: + ```ts + /** The §11 allow-list: phase, app and startedAt, and nothing from the step. */ + export function parseAidenRemoteBotSessionActivity(value: unknown): AidenRemoteBotSessionActivity { + if (!isRecord(value)) throw new Error("Bot session activity must be an object."); + assertExactKeys(value, ["phase", "app", "startedAt"], "Bot session activity"); + const phase = boundedText(value.phase, "Bot session activity phase", 32); + if (!AIDEN_REMOTE_BOT_ACTIVITY_PHASE.test(phase)) throw new Error("Bot session activity phase is invalid."); + return { + phase, + ...(hasOwn(value, "app") + ? { app: boundedText(value.app, "Bot session activity app", AIDEN_REMOTE_BOT_ACTIVITY_APP_MAX_CHARS) } + : {}), + startedAt: boundedIntegerValue(value.startedAt, "Bot session activity startedAt", 0, Number.MAX_SAFE_INTEGER), + }; + } + + export function parseAidenRemoteBotQueuedMessage(value: unknown): AidenRemoteBotQueuedMessage { + if (!isRecord(value)) throw new Error("Bot queued message must be an object."); + assertExactKeys(value, ["submissionId", "mode", "text"], "Bot queued message"); + const submissionId = boundedText(value.submissionId, "Bot queued message submissionId", 64); + if (!AIDEN_REMOTE_BOT_SUBMISSION_ID.test(submissionId)) throw new Error("Bot queued message submissionId is invalid."); + return { + submissionId, + mode: enumMember(value.mode, AIDEN_REMOTE_BOT_WHEN_BUSY_MODES, "Bot queued message mode"), + text: boundedText(value.text, "Bot queued message text", AIDEN_REMOTE_BOT_MESSAGE_MAX_CHARS), + }; + } + + function parseBotQueuedList(value: unknown): AidenRemoteBotQueuedMessage[] { + if (!Array.isArray(value) || value.length > AIDEN_REMOTE_BOT_QUEUED_MAX) { + throw new Error(`Bot queued messages must contain at most ${AIDEN_REMOTE_BOT_QUEUED_MAX} items.`); + } + const queued = value.map(parseAidenRemoteBotQueuedMessage); + if (new Set(queued.map((message) => message.submissionId)).size !== queued.length) { + throw new Error("Bot queued message ids must be unique."); + } + return queued; + } + ``` +5. In `parseAidenRemoteBotSession`, add `"activity", "queued"` to the `assertExactKeys` list. After `approval: …` in the returned object, add: + ```ts + ...(hasOwn(value, "activity") + ? { activity: value.activity === null ? null : parseAidenRemoteBotSessionActivity(value.activity) } + : {}), + ...(hasOwn(value, "queued") ? { queued: parseBotQueuedList(value.queued) } : {}), + ``` +6. In `parseAidenRemoteBotSessionEvent`'s switch, before `case "closed":`, add: + ```ts + case "activity": + assertExactKeys(payload, ["activity"], "Bot session activity event"); + if (!hasOwn(payload, "activity")) throw new Error("Bot session activity event activity is required."); + return { + ...base, + type, + payload: { activity: payload.activity === null ? null : parseAidenRemoteBotSessionActivity(payload.activity) }, + }; + case "queued": + assertExactKeys(payload, ["queued"], "Bot session queued event"); + return { ...base, type, payload: { queued: parseBotQueuedList(payload.queued) } }; + ``` +7. Replace `parseAidenRemoteBotMessageRequest`: + ```ts + export function parseAidenRemoteBotMessageRequest( + value: unknown, + options: { allowWhenBusy?: boolean } = {}, + ): AidenRemoteBotMessageRequest { + if (!isRecord(value)) throw new Error("Bot message request must be an object."); + assertExactKeys(value, options.allowWhenBusy === true ? ["text", "whenBusy"] : ["text"], "Bot message request"); + const text = boundedText(value.text, "Bot message text", AIDEN_REMOTE_BOT_MESSAGE_MAX_CHARS); + if (text.trim().length === 0) throw new Error("Bot message text must not be blank."); + return { + text, + ...(hasOwn(value, "whenBusy") + ? { whenBusy: enumMember(value.whenBusy, AIDEN_REMOTE_BOT_WHEN_BUSY_MODES, "Bot message whenBusy") } + : {}), + }; + } + ``` + +- [ ] **Step 6: The fixture section parser** + +1. Add to `interface AidenRemoteContractFixture`, below `botSessionCards`: + ```ts + /** Revision 30: activity sessions, frames, Chat activity and the phase table every client draws. */ + botActivity?: AidenRemoteBotActivityFixture; + ``` +2. Above `export function parseAidenRemoteContractFixture(`, add: + ```ts + export interface AidenRemoteBotActivityFixture { + /** §5 in order: every phase id with its word and mark. */ + phases: Array<{ id: string; word: string; mark: string }>; + /** Phases that keep their word under Always "Thinking". */ + thinkingKeeps: string[]; + /** "Using {app}" with a sample label. */ + app: { app: string; word: string }; + /** Elapsed labels, including a phone whose clock is behind the Mac's. */ + elapsed: Array<{ startedAt: number; now: number; label: string | null }>; + session: AidenRemoteBotSession; + events: AidenRemoteBotSessionEvent[]; + detail: AidenRemoteBotDetail; + identityPatch: { request: AidenRemoteBotIdentityPatchRequest; response: AidenRemoteBotDetail }; + send: { request: AidenRemoteBotMessageRequest; response: AidenRemoteBotMessageReceipt }; + } + + function parseBotActivityFixture(value: unknown): AidenRemoteBotActivityFixture { + if (!isRecord(value)) throw new Error("Bot activity fixture must be an object."); + assertExactKeys( + value, + ["phases", "thinkingKeeps", "app", "elapsed", "session", "events", "detail", "identityPatch", "send"], + "Bot activity fixture", + ); + if (!Array.isArray(value.events)) throw new Error("Bot activity fixture events must be an array."); + const identity = requestResponseRecord(value.identityPatch, "Bot activity identity patch"); + const send = requestResponseRecord(value.send, "Bot activity send"); + return { + phases: value.phases as AidenRemoteBotActivityFixture["phases"], + thinkingKeeps: value.thinkingKeeps as string[], + app: value.app as AidenRemoteBotActivityFixture["app"], + elapsed: value.elapsed as AidenRemoteBotActivityFixture["elapsed"], + session: parseAidenRemoteBotSession(value.session), + events: value.events.map(parseAidenRemoteBotSessionEvent), + detail: parseAidenRemoteBotDetail(value.detail), + identityPatch: { + request: parseAidenRemoteBotIdentityPatchRequest(identity.request, { allowChatActivity: true }), + response: parseAidenRemoteBotDetail(identity.response), + }, + send: { + request: parseAidenRemoteBotMessageRequest(send.request, { allowWhenBusy: true }), + response: parseAidenRemoteBotMessageReceipt(send.response), + }, + }; + } + ``` +3. In `parseAidenRemoteContractFixture`, directly before `assertNoForbiddenWireKeys(value);` (after the chat-visuals check, so older-revision tests keep failing on their own section first), add: + ```ts + let botActivity: AidenRemoteBotActivityFixture | undefined; + if (value.botActivity !== undefined) { + if (contractRevision < 30) throw new Error("Bot activity fixtures require contract revision 30."); + botActivity = parseBotActivityFixture(value.botActivity); + } + ``` + In the returned object, add `...(botActivity ? { botActivity } : {}),` as the last property. Do **not** add `botActivity` or `botActivityPrivacy` to `AIDEN_REMOTE_PRIVATE_BOT_FIXTURE_ROOTS`: the privacy section is made of refused samples, and the strict parsers above are the allow-list. + +- [ ] **Step 7: The shared fixture** + +In `protocol/aiden-remote/v1/fixtures/contract.json`: +- Change both `"contractRevision": 29,` lines (top level and `hostHealth`) to `30`. +- The file ends with the `botSessionCards` object: + ```json + "hasOlder": false, + "question": null, + "approval": null + } + } + ``` + Replace the last two lines (` }` and `}`) with: + +```json + }, + "botActivity": { + "phases": [ + { "id": "preparing", "word": "Getting ready", "mark": "bounce" }, + { "id": "thinking", "word": "Thinking", "mark": "tri-step" }, + { "id": "reading", "word": "Reading", "mark": "scan-grid" }, + { "id": "photo", "word": "Looking at your photo", "mark": "scan-grid" }, + { "id": "searching", "word": "Searching the web", "mark": "scan-grid" }, + { "id": "command", "word": "Running a command", "mark": "quad-shuffle" }, + { "id": "editing", "word": "Editing files", "mark": "quad-shuffle" }, + { "id": "app", "word": "Using an app", "mark": "quad-shuffle" }, + { "id": "helper", "word": "Asking a helper", "mark": "quad-shuffle" }, + { "id": "computer", "word": "Using your Mac", "mark": "quad-shuffle" }, + { "id": "remembering", "word": "Remembering", "mark": "compose" }, + { "id": "routine", "word": "Setting up a routine", "mark": "tri-step" }, + { "id": "working", "word": "Working", "mark": "quad-shuffle" }, + { "id": "writing", "word": "Writing", "mark": "compose" }, + { "id": "waiting", "word": "Waiting for you", "mark": "glance" }, + { "id": "tidying", "word": "Tidying up its notes", "mark": "bounce" }, + { "id": "stopping", "word": "Stopping", "mark": "bounce" }, + { "id": "resuming", "word": "Picking up where it left off", "mark": "bounce" }, + { "id": "reconnecting", "word": "Reconnecting to your Mac", "mark": "bounce" } + ], + "thinkingKeeps": ["waiting", "stopping", "reconnecting"], + "app": { "app": "Notion", "word": "Using Notion" }, + "elapsed": [ + { "startedAt": 1760450000000, "now": 1760450019999, "label": null }, + { "startedAt": 1760450000000, "now": 1760450020000, "label": "20s" }, + { "startedAt": 1760450000000, "now": 1760450072000, "label": "1m 12s" }, + { "startedAt": 1760450000000, "now": 1760450120000, "label": "2m" }, + { "startedAt": 1760450000000, "now": 1760453720000, "label": "1h 2m" }, + { "startedAt": 1760450060000, "now": 1760450000000, "label": null } + ], + "session": { + "botId": "bot_fixture_01", + "epoch": "epoch_fixture_02", + "seq": 7, + "state": "running", + "interrupted": false, + "entries": [ + { "type": "message", "id": "entry_30", "role": "user", "text": "Plan dinners for this week.", "createdAt": "2026-10-14T09:00:00.000Z" } + ], + "hasOlder": false, + "question": null, + "approval": null, + "activity": { "phase": "app", "app": "Notion", "startedAt": 1760450000000 }, + "queued": [ + { "submissionId": "18", "mode": "queue", "text": "Also add a shopping list." }, + { "submissionId": "19", "mode": "steer", "text": "Keep it vegetarian." } + ] + }, + "events": [ + { "protocolVersion": 1, "botId": "bot_fixture_01", "epoch": "epoch_fixture_02", "seq": 8, "type": "activity", "payload": { "activity": { "phase": "searching", "startedAt": 1760450000000 } } }, + { "protocolVersion": 1, "botId": "bot_fixture_01", "epoch": "epoch_fixture_02", "seq": 9, "type": "queued", "payload": { "queued": [ { "submissionId": "18", "mode": "queue", "text": "Also add a shopping list." } ] } }, + { "protocolVersion": 1, "botId": "bot_fixture_01", "epoch": "epoch_fixture_02", "seq": 10, "type": "activity", "payload": { "activity": { "phase": "teleporting", "startedAt": 1760450000000 } } }, + { "protocolVersion": 1, "botId": "bot_fixture_01", "epoch": "epoch_fixture_02", "seq": 11, "type": "activity", "payload": { "activity": null } } + ], + "detail": { + "id": "bot_fixture_01", + "name": "Scout", + "purpose": "Finds the signal in a busy week", + "instructions": "Find the important signal, explain it plainly, and keep the owner in control.", + "avatar": { "semantic": { "version": 1, "shape": "orb", "color": "rose" }, "asset": { "assetRevision": "avatar_revision_3", "mimeType": "image/png", "width": 512, "height": 512, "byteSize": 1024 } }, + "health": "ready", + "sessionState": "running", + "access": { "botId": "bot_fixture_01", "accessMode": "full", "revision": "bot_policy_revision_4", "policyEpoch": "bot_policy_epoch_4", "summary": "Can use your Mac, shell, enabled connections, and skills." }, + "chatActivity": { "level": "summary", "wording": "thinking" }, + "createdAt": "2026-08-18T17:00:00.000Z", + "updatedAt": "2026-08-18T18:46:00.000Z", + "revision": "bot_revision_8" + }, + "identityPatch": { + "request": { "chatActivity": { "level": "detailed", "wording": "follows" } }, + "response": { + "id": "bot_fixture_01", + "name": "Scout", + "purpose": "Finds the signal in a busy week", + "instructions": "Find the important signal, explain it plainly, and keep the owner in control.", + "avatar": { "semantic": { "version": 1, "shape": "orb", "color": "rose" }, "asset": { "assetRevision": "avatar_revision_3", "mimeType": "image/png", "width": 512, "height": 512, "byteSize": 1024 } }, + "health": "ready", + "access": { "botId": "bot_fixture_01", "accessMode": "full", "revision": "bot_policy_revision_4", "policyEpoch": "bot_policy_epoch_4", "summary": "Can use your Mac, shell, enabled connections, and skills." }, + "chatActivity": { "level": "detailed", "wording": "follows" }, + "createdAt": "2026-08-18T17:00:00.000Z", + "updatedAt": "2026-08-18T18:47:00.000Z", + "revision": "bot_revision_9" + } + }, + "send": { + "request": { "text": "Keep it vegetarian.", "whenBusy": "steer" }, + "response": { "submissionId": "19", "deduped": false, "state": "running", "interrupted": false } + } + }, + "botActivityPrivacy": { + "note": "Every decoder must refuse each sample: only phase, app and startedAt cross the wire.", + "rejectedActivities": [ + { "phase": "command", "startedAt": 1760450000000, "detail": "cat ~/Documents/plan.md" }, + { "phase": "reading", "startedAt": 1760450000000, "step": { "kind": "reading", "label": "Reading", "detail": "plan.md" } }, + { "phase": "reading", "startedAt": 1760450000000, "path": "~/Documents/plan.md" }, + { "phase": "command", "startedAt": 1760450000000, "command": "ls ~/Documents" }, + { "phase": "searching", "startedAt": 1760450000000, "query": "dinner ideas near me" }, + { "phase": "thinking", "startedAt": 1760450000000, "thinking": "The person wants a plan." }, + { "phase": "app", "app": "abcdefghijabcdefghijabcdefghijabcdefghijX", "startedAt": 1760450000000 }, + { "phase": "Reading!", "startedAt": 1760450000000 }, + { "phase": "thinking", "startedAt": -1 }, + { "phase": "thinking", "startedAt": "1760450000000" }, + { "startedAt": 1760450000000 } + ], + "rejectedQueued": [ + { "submissionId": "18", "mode": "followUp", "text": "Also add a shopping list." }, + { "submissionId": "18", "mode": "queue", "text": "Also add a shopping list.", "images": 1 }, + { "submissionId": "18 19", "mode": "queue", "text": "Also add a shopping list." } + ], + "rejectedChatActivity": [ + { "level": "verbose", "wording": "follows" }, + { "level": "quiet" }, + { "level": "quiet", "wording": "follows", "extra": true } + ] + } +} +``` + +The fixture never names a `/Users/` path (`aiden-remote-protocol.test.ts:178` and the iOS Phase 0 scan check this). + +- [ ] **Step 8: OpenAPI** + +In `protocol/aiden-remote/v1/openapi.json`: + +1. Add `"bot:activity"` after each of the four `"bot:cards"` enum items: two under the `/server` response capabilities, and two under `/device/capabilities`. Keep the JSON valid. +2. In the `/device/capabilities` `accepts` description, append: ` Contract revision 30 adds bot:activity, which any device type may negotiate while the server advertises bot-activity-v1: it grants no authority and only opts the device into the activity and queued Bot session frames and Bot detail chatActivity.` +3. In `components.schemas.BotSessionEvent.properties.type.enum`, insert `"activity", "queued"` before `"closed"`. +4. In `components.schemas.BotSession.properties`, add: + ```json + "activity": { "description": "Contract revision 30, only for devices holding bot:activity: what the Bot is doing in its running turn, or null.", "oneOf": [{ "$ref": "#/components/schemas/BotSessionActivity" }, { "type": "null" }] }, + "queued": { "description": "Contract revision 30, only for devices holding bot:activity: the person's messages waiting for the running turn.", "type": "array", "maxItems": 20, "items": { "$ref": "#/components/schemas/BotQueuedMessage" } } + ``` +5. Add to `components.schemas`: + ```json + "BotSessionActivity": { + "description": "Contract revision 30 (bot-activity-v1). Exactly these keys: never a step, detail, path, command, query or thinking. Clients render an unknown phase as working.", + "type": "object", + "required": ["phase", "startedAt"], + "properties": { + "phase": { "type": "string", "pattern": "^[a-z][a-z_]{0,31}$" }, + "app": { "type": "string", "minLength": 1, "maxLength": 40 }, + "startedAt": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 } + }, + "additionalProperties": false + }, + "BotQueuedMessage": { + "type": "object", + "required": ["submissionId", "mode", "text"], + "properties": { + "submissionId": { "type": "string", "pattern": "^[A-Za-z0-9._:-]{1,64}$" }, + "mode": { "enum": ["steer", "queue"] }, + "text": { "type": "string", "minLength": 1, "maxLength": 32000 } + }, + "additionalProperties": false + }, + "BotChatActivity": { + "type": "object", + "required": ["level", "wording"], + "properties": { + "level": { "enum": ["quiet", "summary", "detailed"] }, + "wording": { "enum": ["follows", "thinking"] } + }, + "additionalProperties": false + } + ``` +6. In `components.schemas.BotDetail.properties`, add `"chatActivity": { "description": "Contract revision 30, only for devices holding bot:activity.", "$ref": "#/components/schemas/BotChatActivity" }`. +7. In `components.schemas.BotIdentityPatch.properties`, add as the **last** property `"chatActivity": { "description": "Contract revision 30, only on hosts announcing bot-activity-v1. Absent keeps the saved choice.", "$ref": "#/components/schemas/BotChatActivity" }`. +8. In `components.schemas.BotMessageRequest.properties`, add `"whenBusy": { "description": "Contract revision 30, only on hosts announcing bot-activity-v1: steer joins the running turn; queue (or no value) runs after it.", "enum": ["steer", "queue"] }`. + +- [ ] **Step 9: Run the tests to verify they pass** + +Run: `npx tsx --test main/services/aiden-remote-protocol.test.ts main/services/aiden-remote-operation-contract.test.ts main/services/aiden-remote-router.test.ts` +Expected: PASS. + +Run: `npm run type-check` +Expected: PASS. + +Run: `cd android && JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:testDebugUnitTest --console=plain --tests '*Contract*'` +Expected: PASS. The new fixture sections are top-level keys that the existing decoders ignore. + +- [ ] **Step 10: Commit** + +```bash +git add main/services/aiden-remote-protocol.ts main/services/aiden-remote-protocol.test.ts main/services/aiden-remote-operation-contract.test.ts main/services/aiden-remote-router.test.ts protocol/aiden-remote/v1/fixtures/contract.json protocol/aiden-remote/v1/openapi.json ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift +git commit -m "Remote revision 30: Bot activity, queued and chatActivity wire shapes with shared fixtures" +``` + +--- + +### Task 2: Remote session activity and queued frames, `whenBusy`, shared Stop/Resume intents + +**Files:** +- Create: `main/services/bot-runtime/bot-turn-intents.ts`, `main/services/bot-runtime/bot-turn-intents-main.ts` +- Modify: `main/services/aiden-remote-bot-session.ts`, `main/services/bot-runtime/bot-session-main.ts`, `main/handlers/bots.ts`, `main/services/aiden-remote-service-main.ts` +- Test: `main/services/aiden-remote-bot-session.test.ts` + +**Interfaces:** +- Consumes: + - Task 1's types and parsers. + - `liveBotActivity`, `BotLiveActivityInput` and `liveQueuedMessages` (B Tasks 3 and 8). + - `BotLiveActivity` (B Task 3). + - `BotLiveProjection.notifyStopping/notifyResuming` (B Task 3). +- Produces: + - `type BotTurnIntent = "stopping" | "resuming"`, `interface BotTurnIntents { announce(botId, intent): void; onIntent(listener): () => void }`, `createBotTurnIntents()` and the singleton `botTurnIntents`. + - `AidenRemoteBotSessionRuntime.send` input gains `whenBusy?: "steer" | "followUp"`. + - `AidenRemoteBotSessionServiceOptions.turnIntents?: Pick`. + - `AidenRemoteBotSessionService.supportsActivity: boolean`. + - `session(botId, { cards?, activity? })` and `openEvents(…, { cards?, activity? })`. + - Exports `projectBotSessionActivity(live)` and `projectBotSessionQueued(docs)`. + +- [ ] **Step 1: Write the failing tests** + +In `main/services/aiden-remote-bot-session.test.ts`: + +1. Extend the imports: + ```ts + import { BOT_ACTIVITY_PHASES } from "../../renderer/shared/bot-activity.js"; + import { createBotTurnIntents } from "./bot-runtime/bot-turn-intents.js"; + ``` +2. In `drivenConversation`, add a method next to `emit`: + ```ts + /** One watch frame with exactly these docs (`pi.live`, `pi.inbox`). */ + async emitView(entries: RawEntry[], docs: Record) { + await listener!({ entries, docs }); + }, + ``` +3. Append these helpers and tests: + +```ts +// --------------------------------------------------------------------------- +// Contract revision 30: activity and queued frames (`bot:activity`) +// --------------------------------------------------------------------------- + +/** Serve one Bot's SSE stream to a device with these opt-ins; keep the raw data lines too. */ +async function openAudienceStream( + service: AidenRemoteBotSessionService, + audience: { cards?: boolean; activity?: boolean }, + botId = BOT_ID, +) { + const server = createServer((_request, response) => { + void service.openEvents("device_1", botId, response, () => {}, audience).catch(() => response.destroy()); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/`); + const frames: Frame[] = []; + const raw: string[] = []; + const decoder = new TextDecoder(); + let buffer = ""; + const reading = (async () => { + for await (const chunk of response.body as unknown as AsyncIterable) { + buffer += decoder.decode(chunk, { stream: true }); + let index: number; + while ((index = buffer.indexOf("\n\n")) >= 0) { + const block = buffer.slice(0, index); + buffer = buffer.slice(index + 2); + const data = block.split("\n").find((line) => line.startsWith("data: ")); + if (!data) continue; + raw.push(data.slice(6)); + frames.push(parseAidenRemoteBotSessionEvent(JSON.parse(data.slice(6))) as unknown as Frame); + } + } + })(); + return { + frames, + raw, + async until(predicate: () => boolean, what: string) { + const deadline = Date.now() + 10_000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error(`Timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }, + async close() { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await reading.catch(() => undefined); + }, + }; +} + +/** The phases a device saw, in order, with repeats folded; null is "no turn running". */ +function phasesOf(frames: Frame[]): Array { + const phases: Array = []; + for (const frame of frames) { + const activity = frame.type === "activity" + ? frame.payload.activity + : frame.type === "snapshot" ? frame.payload.session.activity : undefined; + if (activity === undefined) continue; + const phase = activity === null ? null : (activity.phase as string); + if (phases.length === 0 || phases[phases.length - 1] !== phase) phases.push(phase); + } + return phases; +} + +function runningOn(driven: ReturnType, fake: ReturnType) { + return new AidenRemoteBotSessionService({ + bots: botService(), + runtime: async () => ({ ...fake.runtime, conversation: async () => driven.conversation as never }), + turnIntents: createBotTurnIntents(), + }); +} + +// Test 1 +test("a phone holding bot:activity follows a real turn's phases and never sees what the tool was given", async () => { + const profileDir = mkdtempSync(path.join(os.tmpdir(), "aiden-remote-bot-activity-")); + durableRoots.push(profileDir); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + const command = countingTool("run_command", async () => { + await held; + return "done"; + }); + const planted = "cat /Users/dana/Documents/plan.md && echo sk-live-0123456789abcdef"; + const fauxModels = createFauxModels([ + fauxAssistantMessage([fauxToolCall("run_command", { text: planted })], { stopReason: "toolUse" }), + fauxAssistantMessage("Tidied."), + ]); + const runtime = await createBotSessionService({ + profileDir, + models: fauxModels.models, + extension: recordingDeps({ tools: [{ tool: command, replay: "unsafe" }] }), + resolveModel: async () => FAUX_MODEL_REF, + knownBotIds: async () => new Set([BOT_ID]), + }); + const service = new AidenRemoteBotSessionService({ + bots: botService(), + runtime: async () => runtime, + turnIntents: createBotTurnIntents(), + }); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the first snapshot"); + assert.equal(stream.frames[0]!.payload.session.activity, null, "an idle Bot is doing nothing"); + assert.deepEqual(stream.frames[0]!.payload.session.queued, []); + + await service.send(DEVICE_ID, BOT_ID, "send-request-activity-0001", { text: "Tidy my notes" }); + await stream.until(() => phasesOf(stream.frames).includes("command"), "Running a command"); + release(); + await stream.until( + () => phasesOf(stream.frames).at(-1) === null && phasesOf(stream.frames).length > 2, + "the turn to end", + ); + + const phases = phasesOf(stream.frames); + for (const phase of phases) { + assert.ok(phase === null || (BOT_ACTIVITY_PHASES as readonly string[]).includes(phase), String(phase)); + } + for (const frame of stream.frames) { + const activity = frame.type === "activity" ? frame.payload.activity : undefined; + if (activity) assert.deepEqual(Object.keys(activity).filter((key) => !["phase", "app", "startedAt"].includes(key)), []); + } + const wire = stream.raw.join("\n"); + for (const secret of ["/Users/dana", "plan.md", "sk-live", "echo "]) { + assert.equal(wire.includes(secret), false, `${secret} reached the phone`); + } + const seqs = stream.frames.map((frame) => frame.seq); + assert.deepEqual(seqs, seqs.map((_, index) => seqs[0]! + index), "gapless"); + } finally { + release(); + await stream.close(); + await service.close(); + await runtime.shutdown(); + } +}); + +// Test 2 +test("a device without bot:activity gets the state it already has at each activity seq, and no activity keys", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const service = runningOn(driven, fake); + const activity = await openAudienceStream(service, { activity: true }); + const legacy = await openAudienceStream(service, {}); + try { + await activity.until(() => activity.frames.length >= 1, "the activity snapshot"); + await legacy.until(() => legacy.frames.length >= 1, "the legacy snapshot"); + assert.equal(activity.frames[0]!.payload.session.activity.phase, "preparing", "running with nothing from the model yet"); + assert.equal("activity" in legacy.frames[0]!.payload.session, false); + assert.equal("queued" in legacy.frames[0]!.payload.session, false); + + await driven.emitView([user(1, "Plan dinners")], { + "pi.live": { run: {}, tools: [{ callId: "call_1", name: "web_search", status: "running" }] }, + }); + await activity.until(() => activity.frames.length >= 2, "the searching frame"); + await legacy.until(() => legacy.frames.length >= 2, "the no-op frame"); + + assert.equal(activity.frames[1]!.type, "activity"); + assert.deepEqual(Object.keys(activity.frames[1]!.payload.activity).sort(), ["phase", "startedAt"]); + assert.equal(activity.frames[1]!.payload.activity.phase, "searching"); + assert.equal(legacy.frames[1]!.type, "state"); + assert.deepEqual(legacy.frames[1]!.payload, { state: "running", interrupted: false }); + assert.equal(legacy.frames[1]!.seq, activity.frames[1]!.seq, "every device stays on one seq"); + assert.equal(legacy.raw.some((data) => data.includes("web_search") || data.includes("searching")), false); + } finally { + await activity.close(); + await legacy.close(); + await service.close(); + } +}); + +// Test 3 (Review Focus 4) +test("a Stop pressed anywhere reads Stopping on phones until the run ends, and a Stop with nothing running changes nothing", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const intents = createBotTurnIntents(); + const service = new AidenRemoteBotSessionService({ + bots: botService(), + runtime: async () => ({ ...fake.runtime, conversation: async () => driven.conversation as never }), + turnIntents: intents, + }); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the snapshot"); + // The run is live in `pi.live` (slice B reads Stop only while a run exists). + await driven.emitView([user(1, "Plan dinners")], { "pi.live": { run: {} } }); + intents.announce(BOT_ID, "stopping"); // the Mac's Stop + await stream.until(() => phasesOf(stream.frames).includes("stopping"), "Stopping"); + + fake.setState({ kind: "idle" }); + await driven.emitView([user(1, "Plan dinners"), assistant(2, "Here is a start.")], {}); + await stream.until(() => phasesOf(stream.frames).at(-1) === null, "the run to end"); + + const settled = stream.frames.length; + intents.announce(BOT_ID, "stopping"); // a double-tap after the reply landed + await service.session(BOT_ID, { activity: true }); // runs after the intent on the projector's lane + assert.equal(stream.frames.length, settled, "nothing was running, so nothing changed"); + + fake.setState({ kind: "running", submissionId: "sub_2" }); + await driven.emitView( + [user(1, "Plan dinners"), assistant(2, "Here is a start."), user(3, "And lunches")], + { "pi.live": { run: {} } }, + ); + await stream.until(() => phasesOf(stream.frames).at(-1) !== null, "the next run"); + assert.equal(phasesOf(stream.frames).at(-1), "preparing", "the next turn starts at Getting ready"); + } finally { + await stream.close(); + await service.close(); + } +}); + +// Test 4 +test("Steer and Queue reach the runtime as steer and followUp, and the person's waiting messages are listed", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const sends: Array<{ text: string; whenBusy?: string }> = []; + const service = new AidenRemoteBotSessionService({ + bots: botService(), + runtime: async () => ({ + ...fake.runtime, + conversation: async () => driven.conversation as never, + async send(_botId: string, input: { text: string; requestId: string; whenBusy?: "steer" | "followUp" }) { + sends.push({ text: input.text, ...(input.whenBusy ? { whenBusy: input.whenBusy } : {}) }); + return { submissionId: String(17 + sends.length), deduped: false }; + }, + }), + turnIntents: createBotTurnIntents(), + }); + const stream = await openAudienceStream(service, { activity: true }); + try { + await service.send(DEVICE_ID, BOT_ID, "send-request-steer-0001", { text: "Keep it vegetarian.", whenBusy: "steer" }); + await service.send(DEVICE_ID, BOT_ID, "send-request-queue-0001", { text: "Also a shopping list.", whenBusy: "queue" }); + await service.send(DEVICE_ID, BOT_ID, "send-request-plain-0001", { text: "Thanks!" }); + assert.deepEqual(sends, [ + { text: "Keep it vegetarian.", whenBusy: "steer" }, + { text: "Also a shopping list.", whenBusy: "followUp" }, + { text: "Thanks!" }, + ]); + + await stream.until(() => stream.frames.length >= 1, "the snapshot"); + await driven.emitView([user(1, "Plan dinners")], { + "pi.live": { run: {} }, + "pi.inbox": { + items: [ + { id: 18, mode: "steer", content: [{ type: "text", text: "Keep it vegetarian." }] }, + { id: 19, mode: "write", entry: { kind: BOT_NOTICE_ENTRY_KIND, data: { notice: "routine" } } }, + { id: 20, mode: "followUp", content: [{ type: "text", text: "Weekly reset: plan the week." }] }, + { id: 21, mode: "followUp", content: [{ type: "text", text: "Also a shopping list." }] }, + ], + }, + }); + await stream.until(() => stream.frames.some((frame) => frame.type === "queued"), "the queued frame"); + assert.deepEqual(stream.frames.find((frame) => frame.type === "queued")!.payload.queued, [ + { submissionId: "18", mode: "steer", text: "Keep it vegetarian." }, + { submissionId: "21", mode: "queue", text: "Also a shopping list." }, + ], "a routine's input is not the person's message"); + } finally { + await stream.close(); + await service.close(); + } +}); + +// Test 5 +test("a host without bot-activity-v1 refuses whenBusy instead of guessing", async () => { + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const service = sessionService(fake.runtime); + assert.equal(service.supportsActivity, false); + await assert.rejects( + service.send(DEVICE_ID, BOT_ID, "send-request-steer-0002", { text: "Hi", whenBusy: "steer" }), + isError("invalid_request", 400), + ); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx tsx --test main/services/aiden-remote-bot-session.test.ts` +Expected: FAIL. `./bot-runtime/bot-turn-intents.js` does not exist, and the service has no `turnIntents` option. + +- [ ] **Step 3: The intents emitter** + +Create `main/services/bot-runtime/bot-turn-intents.ts`: + +```ts +// Stop and Resume, shared by every surface that shows a Bot's activity. The +// desktop live projection and the Remote session projector both turn these +// into "Stopping" and "Picking up where it left off" (spec §5), so a Stop +// pressed on the Mac or on a phone reads the same everywhere. + +export type BotTurnIntent = "stopping" | "resuming"; + +export interface BotTurnIntents { + /** A person pressed Stop or Resume for `botId`. Each listener decides whether a turn is running. */ + announce(botId: string, intent: BotTurnIntent): void; + onIntent(listener: (botId: string, intent: BotTurnIntent) => void): () => void; +} + +export function createBotTurnIntents(): BotTurnIntents { + const listeners = new Set<(botId: string, intent: BotTurnIntent) => void>(); + return { + announce(botId, intent) { + for (const listener of [...listeners]) { + try { + listener(botId, intent); + } catch { + // A status line that can't update must never block the Stop itself. + } + } + }, + onIntent(listener) { + listeners.add(listener); + return () => void listeners.delete(listener); + }, + }; +} +``` + +Create `main/services/bot-runtime/bot-turn-intents-main.ts`: + +```ts +// The process-wide Bot turn intents. Desktop Stop/Resume (`main/handlers/bots.ts`) +// and Remote Stop/Resume (`aiden-remote-bot-session.ts`) announce here; the +// desktop live projection (`bot-session-main.ts`) and Remote projectors listen. + +import { createBotTurnIntents } from "./bot-turn-intents.js"; + +export const botTurnIntents = createBotTurnIntents(); +``` + +- [ ] **Step 4: The Remote projector** + +In `main/services/aiden-remote-bot-session.ts`: + +1. Imports: + - Change the live-projection import to `import { liveBotActivity, livePartialText, liveQueuedMessages, projectBotTranscript } from "./bot-runtime/live-projection.js";`. + - Add `import type { BotLiveActivity } from "../../renderer/shared/bot-live.js";` and `import type { BotTurnIntent, BotTurnIntents } from "./bot-runtime/bot-turn-intents.js";`. + - Add to the protocol import: `AIDEN_REMOTE_BOT_ACTIVITY_APP_MAX_CHARS`, `AIDEN_REMOTE_BOT_MESSAGE_MAX_CHARS`, `AIDEN_REMOTE_BOT_QUEUED_MAX`, `type AidenRemoteBotQueuedMessage` and `type AidenRemoteBotSessionActivity`. +2. Update the file header. After the approval bullet, add: + ```ts + // - Contract revision 30 (`bot-activity-v1`): devices holding `bot:activity` + // also get `activity` (the §5 phase, cut to `{ phase, app?, startedAt }`) and + // `queued` (the person's waiting messages) in the snapshot and as frames. + // Other devices get the state they already hold at those seqs. + ``` +3. In `AidenRemoteBotSessionRuntime`, change `send`'s input to `{ text: string; requestId: string; whenBusy?: "steer" | "followUp" }`. +4. In `AidenRemoteBotSessionServiceOptions`, add: + ```ts + /** + * Stop and Resume from any surface (`botTurnIntents`). Present means the host + * projects activity and queued frames and accepts `whenBusy` (revision 30). + */ + turnIntents?: Pick; + ``` +5. Below `projectBotSessionState`, add: + ```ts + /** The §11 allow-list, picked by name: nothing from the step crosses the wire. */ + export function projectBotSessionActivity( + live: Pick, + ): AidenRemoteBotSessionActivity { + const app = live.phase === "app" && live.app + ? wireText(live.app, AIDEN_REMOTE_BOT_ACTIVITY_APP_MAX_CHARS).trim() + : ""; + return { + phase: live.phase, + ...(app ? { app } : {}), + startedAt: Math.max(0, Math.floor(live.startedAt)), + }; + } + + /** The person's waiting messages; routine and hidden inputs never show (slice B). */ + export function projectBotSessionQueued(docs: Readonly>): AidenRemoteBotQueuedMessage[] { + return liveQueuedMessages(docs) + .filter((message) => message.text.trim().length > 0) + .slice(0, AIDEN_REMOTE_BOT_QUEUED_MAX) + .map((message) => ({ + submissionId: message.submissionId, + mode: message.mode === "steer" ? "steer" : "queue", + text: wireText(message.text, AIDEN_REMOTE_BOT_MESSAGE_MAX_CHARS), + })); + } + ``` +6. Replace the `Subscriber` interface with: + ```ts + /** What one device opted into. It changes what the device is sent, never what it may do. */ + interface Audience { + /** `bot:cards`: memory and routine proposal entries (revision 27). */ + cards: boolean; + /** `bot:activity`: the `activity` and `queued` frames and snapshot keys (revision 30). */ + activity: boolean; + } + + interface Subscriber extends Audience { + frames: string[]; + handle?: CursorSseHandle; + overflowed: boolean; + } + + /** Frames only `bot:activity` devices can parse. */ + const ACTIVITY_FRAME_TYPES: ReadonlySet = new Set(["activity", "queued"]); + ``` +7. Replace the whole `class BotLiveProjector { … }` with: + +```ts +class BotLiveProjector { + readonly epoch = `epoch_${randomUUID().replace(/-/gu, "")}`; + seq = 0; + entries: AidenRemoteBotSessionEntry[] = []; + partial: string | undefined; + state: AidenRemoteBotSessionStateView = { state: "idle", interrupted: false }; + question: AidenRemoteBotQuestion | null = null; + approval: AidenRemoteBotApproval | null = null; + /** What the running turn is doing, in the §11 shape; null while no turn runs. */ + activity: AidenRemoteBotSessionActivity | null = null; + /** The person's messages waiting for the running turn. */ + queued: AidenRemoteBotQueuedMessage[] = []; + readonly subscribers = new Set(); + private view: ConversationView | undefined; + private stopWatch: (() => Promise) | undefined; + private lane: Promise = Promise.resolve(); + private idleTimer: ReturnType | undefined; + /** When the current running turn started, on this Mac's clock. */ + private runStartedAt = 0; + /** Stop was pressed during this run. */ + private stopping = false; + /** This run is a Resume and has not moved on yet. */ + private resuming = false; + /** Resume was pressed: the next run reads "Picking up where it left off". */ + private resumeRequested = false; + closed = false; + + constructor( + readonly botId: string, + private readonly readState: () => Promise, + private readonly resolveEntries: (entries: AidenRemoteBotSessionEntry[]) => Promise, + private readonly idleMs: number, + private readonly onClosed: () => void, + private readonly readQuestion: () => AidenRemoteBotQuestion | null = () => null, + private readonly readApproval: () => AidenRemoteBotApproval | null = () => null, + private readonly now: () => number = Date.now, + /** The host wires revision 30; without it nothing is derived and no activity frame is ever sent. */ + private readonly activityEnabled = false, + ) {} + + async attach(conversation: Conversation): Promise { + const watch = await conversation.watch(ctx); + this.stopWatch = () => watch.stop(); + this.view = watch.value; + this.entries = await this.resolveEntries(projectBotSessionEntries(watch.value.entries)); + this.partial = projectBotSessionPartial(watch.value); + this.state = await this.readState(); + this.question = this.readQuestion(); + this.approval = this.readApproval(); + this.trackRun({ state: "idle", interrupted: false }, this.state); + if (this.activityEnabled) { + this.activity = this.deriveActivity(watch.value, this.state, this.question !== null || this.approval !== null); + this.queued = projectBotSessionQueued(watch.value.docs); + } + watch.start(async (view) => { + await this.advance(view); + }); + void watch.closed.then(() => this.close(), () => this.close()); + this.armIdle(); + } + + /** The session as one device sees it. */ + snapshot(audience: Audience): AidenRemoteBotSession { + return parseAidenRemoteBotSession({ + botId: this.botId, + epoch: this.epoch, + seq: this.seq, + ...this.state, + ...(this.partial !== undefined ? { partial: this.partial } : {}), + ...windowed(audience.cards ? this.entries : withoutBotCards(this.entries)), + question: this.question, + approval: this.approval, + ...(audience.activity ? { activity: this.activity, queued: this.queued } : {}), + }); + } + + /** The `snapshot` frame for the current seq (first frame of a stream, or a history rewrite). */ + snapshotFrame(audience: Audience): string { + const session = this.snapshot(audience); + return sseFrame(`${session.epoch}:${session.seq}`, "snapshot", { + protocolVersion: AIDEN_REMOTE_PROTOCOL_VERSION, + botId: session.botId, + epoch: session.epoch, + seq: session.seq, + type: "snapshot", + payload: { session }, + }); + } + + /** Renders a frame once per kind of audience and queues it for every subscriber. */ + private broadcast(render: (audience: Audience) => string): void { + const rendered = new Map(); + for (const subscriber of this.subscribers) { + if (subscriber.frames.length >= MAX_PENDING_FRAMES) { + // A stalled client is dropped; it reconnects and gets a fresh snapshot. + subscriber.overflowed = true; + } else { + const key = `${subscriber.cards}:${subscriber.activity}`; + let text = rendered.get(key); + if (text === undefined) { + text = render(subscriber); + rendered.set(key, text); + } + subscriber.frames.push(text); + } + subscriber.handle?.wake(); + } + } + + private frame(event: Omit): void { + this.seq += 1; + const data = { + protocolVersion: AIDEN_REMOTE_PROTOCOL_VERSION, + botId: this.botId, + epoch: this.epoch, + seq: this.seq, + ...event, + }; + const id = `${this.epoch}:${this.seq}`; + const text = sseFrame(id, event.type, data); + const card = + event.type === "entry" && + (AIDEN_REMOTE_BOT_CARD_ENTRY_TYPES as readonly string[]).includes( + (event.payload as { entry?: { type?: string } }).entry?.type ?? "", + ); + const activityOnly = ACTIVITY_FRAME_TYPES.has(event.type); + // A frame a device can't parse must still reach it as its seq: clients + // refetch on any gap. That device gets the state it already holds instead, + // which changes nothing it shows. + const noOp = sseFrame(id, "state", { ...data, type: "state", payload: this.state }); + this.broadcast((audience) => + (card && !audience.cards) || (activityOnly && !audience.activity) ? noOp : text, + ); + } + + /** + * Watch callbacks and refreshes both project asynchronously. They run one at + * a time so a slow, older read can never publish after a newer one: frames + * stay in causal order and every seq is exactly one more than the last. + */ + private serial(action: () => Promise): Promise { + const run = this.lane.then(action, action); + this.lane = run.then(() => undefined, () => undefined); + return run; + } + + advance(view: ConversationView): Promise { + return this.serial(async () => { + this.view = view; + await this.publish(view); + }); + } + + /** + * Re-project the latest view and re-read the state: Resume, Dismiss, Stop, + * a lock change or a connection finished on the Mac change what a client + * should see without committing to the conversation. + */ + refresh(): Promise { + return this.serial(async () => { + if (this.view) await this.publish(this.view); + }); + } + + /** Stop or Resume pressed anywhere. A Stop with nothing running changes nothing. */ + noteIntent(intent: BotTurnIntent): Promise { + return this.serial(async () => { + if (intent === "stopping") { + if (this.state.state !== "running") return; + this.stopping = true; + } else { + this.resumeRequested = true; + } + if (this.view) await this.publish(this.view); + }); + } + + /** A turn starting resets Stop and picks up a pending Resume; any other state clears both. */ + private trackRun(previous: AidenRemoteBotSessionStateView, next: AidenRemoteBotSessionStateView): void { + if (next.state === "running" && previous.state !== "running") { + this.runStartedAt = this.now(); + this.stopping = false; + this.resuming = this.resumeRequested; + this.resumeRequested = false; + } else if (next.state !== "running") { + this.stopping = false; + this.resuming = false; + } + } + + /** Slice B's derivation from `pi.live`, cut to the wire. A paused Bot keeps `pi.live.run` but is not working. */ + private deriveActivity( + view: ConversationView, + state: AidenRemoteBotSessionStateView, + waiting: boolean, + ): AidenRemoteBotSessionActivity | null { + if (state.state !== "running") return null; + const live = liveBotActivity({ + entries: view.entries, + docs: view.docs, + waiting, + stopping: this.stopping, + resuming: this.resuming, + startedAt: this.runStartedAt, + }) ?? { phase: "preparing" as const, startedAt: this.runStartedAt }; + if (live.phase !== "resuming") this.resuming = false; + return projectBotSessionActivity(live); + } + + private async publish(view: ConversationView): Promise { + if (this.closed) return; + const next = await this.resolveEntries(projectBotSessionEntries(view.entries)); + const partial = projectBotSessionPartial(view); + const state = await this.readState().catch(() => this.state); + const question = this.readQuestion(); + const approval = this.readApproval(); + if (this.closed) return; + this.trackRun(this.state, state); + const activity = this.activityEnabled + ? this.deriveActivity(view, state, question !== null || approval !== null) + : null; + const queued = this.activityEnabled ? projectBotSessionQueued(view.docs) : []; + + const previous = this.entries; + const previousById = new Map(previous.map((entry) => [entry.id, JSON.stringify(entry)])); + const nextIds = new Set(next.map((entry) => entry.id)); + // Appends and in-place updates are entry events; anything that removes or + // reorders shown history (a `[SILENT]` routine turn hiding its input) is a snapshot. + const kept = previous.filter((entry) => nextIds.has(entry.id)).map((entry) => entry.id); + const order = next.filter((entry) => previousById.has(entry.id)).map((entry) => entry.id); + const rewritten = kept.length !== previous.length || kept.some((id, index) => order[index] !== id); + + this.entries = next; + if (rewritten) { + this.partial = partial; + this.state = state; + this.question = question; + this.approval = approval; + this.activity = activity; + this.queued = queued; + this.seq += 1; + this.broadcast((audience) => this.snapshotFrame(audience)); + return; + } + for (const entry of next) { + if (previousById.get(entry.id) === JSON.stringify(entry)) continue; + this.frame({ type: "entry", payload: { entry } }); + } + if (partial !== this.partial) { + this.partial = partial; + // Always explicit: an empty text clears the partial, so a client never + // has to guess whether an appended entry replaced it. + this.frame({ type: "partial", payload: { text: partial ?? "" } }); + } + if (JSON.stringify(state) !== JSON.stringify(this.state)) { + this.state = state; + this.frame({ type: "state", payload: state }); + } + if (question?.waitId !== this.question?.waitId) { + this.question = question; + this.frame({ type: "question", payload: { question } }); + } + if (approval?.waitId !== this.approval?.waitId) { + this.approval = approval; + this.frame({ type: "approval", payload: { approval } }); + } + // After the state frame, so a phone never sees a phase for a turn that already ended. + if (JSON.stringify(activity) !== JSON.stringify(this.activity)) { + this.activity = activity; + this.frame({ type: "activity", payload: { activity } }); + } + if (JSON.stringify(queued) !== JSON.stringify(this.queued)) { + this.queued = queued; + this.frame({ type: "queued", payload: { queued } }); + } + } + + subscribe(subscriber: Subscriber): () => void { + clearTimeout(this.idleTimer); + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + this.armIdle(); + }; + } + + private armIdle(): void { + clearTimeout(this.idleTimer); + if (this.closed || this.subscribers.size > 0) return; + this.idleTimer = setTimeout(() => void this.close(), this.idleMs); + this.idleTimer.unref?.(); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + clearTimeout(this.idleTimer); + this.frame({ type: "closed", payload: {} }); + this.onClosed(); + await this.stopWatch?.().catch(() => undefined); + } +} +``` + +- [ ] **Step 5: The service** + +Still in `aiden-remote-bot-session.ts`, in `AidenRemoteBotSessionService`: + +1. Add the field `private readonly stopIntents: (() => void) | undefined;`. At the end of the constructor, add: + ```ts + // A Stop or Resume pressed on the Mac or on any phone reaches every open session stream. + this.stopIntents = options.turnIntents?.onIntent((botId, intent) => { + void this.projectors.get(botId)?.then((projector) => projector.noteIntent(intent), () => undefined); + }); + ``` +2. Add the getter next to `supportsMemory`: + ```ts + /** Activity and queued frames and `whenBusy` (revision 30, `bot-activity-v1`). */ + get supportsActivity(): boolean { + return Boolean(this.options.turnIntents); + } + ``` +3. In `projector()`, pass two more `BotLiveProjector` arguments after `() => this.approvalOf(botId)`: `() => this.options.now?.() ?? Date.now()` and `this.supportsActivity`. A host without `turnIntents` therefore never sends an extra frame, so revision-29 streams are byte-for-byte unchanged. +4. Replace `session()`: + ```ts + /** `cards`: the device holds `bot:cards`; `activity`: it holds `bot:activity`. */ + async session(botId: string, options: { cards?: boolean; activity?: boolean } = {}): Promise { + const bot = await this.options.bots.bot(botId); + const audience = { cards: options.cards === true, activity: options.activity === true && this.supportsActivity }; + try { + const state = await this.stateView(bot.id); + if (state.state === "unavailable") { + // Bots are held by another Aiden process: nothing can be opened. + return parseAidenRemoteBotSession({ + botId: bot.id, epoch: "epoch_unavailable", seq: 0, ...state, entries: [], hasOlder: false, question: null, approval: null, + ...(audience.activity ? { activity: null, queued: [] } : {}), + }); + } + const projector = await this.liveProjector(bot.id); + // A state change without a commit (a desktop Stop, a lock change) is not + // seen by the watch; a fetch always reports the current state. + await projector.refresh(); + return projector.snapshot(audience); + } catch (error) { + return mapSessionError(error); + } + } + ``` +5. In `openEvents`, change the options parameter to `options: { cards?: boolean; activity?: boolean } = {}`. Replace the two lines `const cards = options.cards === true;` and `const subscriber: Subscriber = …` with: + ```ts + const audience = { cards: options.cards === true, activity: options.activity === true && this.supportsActivity }; + const subscriber: Subscriber = { frames: [projector.snapshotFrame(audience)], overflowed: false, ...audience }; + ``` +6. In `close()`, add `this.stopIntents?.();` as the first line. +7. In `send()`: + - Replace the parse line with `const parsed = parseOrInvalid((value) => parseAidenRemoteBotMessageRequest(value, { allowWhenBusy: this.supportsActivity }), input, "The Bot message is invalid.");`. + - Replace the `runtime.send(…)` call with: + ```ts + const sent = await runtime.send(bot.id, { + text: parsed.text, + requestId: `remote:${deviceId}:${key}`, + // Steer joins the running turn; Queue (or nothing) runs after it. + ...(parsed.whenBusy ? { whenBusy: parsed.whenBusy === "steer" ? "steer" as const : "followUp" as const } : {}), + }); + ``` +8. In `control()`, replace the three action lines inside the idempotent action with: + ```ts + if (action === "resume") { + this.options.turnIntents?.announce(bot.id, "resuming"); + return projectBotSessionState(await runtime.resume(bot.id, requestId)); + } + if (action === "dismiss") return projectBotSessionState(await runtime.dismiss(bot.id, requestId)); + // Stop ends a running turn; a paused one belongs to Dismiss. + this.options.turnIntents?.announce(bot.id, "stopping"); + return projectBotSessionState(await runtime.stop(bot.id)); + ``` + +- [ ] **Step 6: Desktop wiring** + +1. `main/services/bot-runtime/bot-session-main.ts`: add `import { botTurnIntents } from "./bot-turn-intents-main.js";`. Below the `botQuestions.onChange(…)` block, add: + ```ts + // A Stop or Resume from any surface (the Mac or a phone) reads "Stopping" or + // "Picking up where it left off" in open desktop chats; Remote projectors + // listen to the same emitter (`aiden-remote-service-main.ts`). + botTurnIntents.onIntent((botId, intent) => { + if (intent === "stopping") botLiveProjection().notifyStopping(botId); + else botLiveProjection().notifyResuming(botId); + }); + ``` +2. `main/handlers/bots.ts`: add `import { botTurnIntents } from "../services/bot-runtime/bot-turn-intents-main.js";`. In the `bots:resume` and `bots:stop` handlers that slice B Task 3 wrote, replace `botLiveProjection().notifyResuming(botId);` with `botTurnIntents.announce(botId, "resuming");`, and `botLiveProjection().notifyStopping(botId);` with `botTurnIntents.announce(botId, "stopping");`. Drop `botLiveProjection` from that file's imports if nothing else there uses it. +3. `main/services/aiden-remote-service-main.ts`: add `import { botTurnIntents } from "./bot-runtime/bot-turn-intents-main.js";`. In `new AidenRemoteBotSessionService({ … })`, add `turnIntents: botTurnIntents,` after `approvals: botApprovals,`. + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `npx tsx --test main/services/aiden-remote-bot-session.test.ts` +Expected: PASS. That covers the 5 new tests plus the existing ones, including the revision-27 card test, whose legacy stream still gets `state` no-ops. + +Run: `npm run test:bot-runtime && npm run type-check` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add main/services/bot-runtime/bot-turn-intents.ts main/services/bot-runtime/bot-turn-intents-main.ts main/services/aiden-remote-bot-session.ts main/services/aiden-remote-bot-session.test.ts main/services/bot-runtime/bot-session-main.ts main/handlers/bots.ts main/services/aiden-remote-service-main.ts +git commit -m "Remote: Bot activity and queued frames for bot:activity devices; Steer and Queue; shared Stop intents" +``` + +--- + +### Task 3: Bot detail `chatActivity`, `PATCH`, the token and the `bot:activity` opt-in + +**Files:** +- Modify: `main/services/aiden-remote-bots.ts`, `main/services/aiden-remote-router.ts`, `main/services/aiden-remote-state.ts`, `main/services/aiden-remote-service-main.ts` +- Test: `main/services/aiden-remote-bots.test.ts`, `main/services/aiden-remote-router.test.ts`, `main/services/aiden-remote-state.test.ts` + +**Interfaces:** +- Consumes: `parseAidenRemoteBotIdentityPatchRequest(value, { allowChatActivity })`, `AIDEN_REMOTE_BOT_ACTIVITY_FEATURE`, `AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES` and `AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES` (Task 1). `AidenRemoteBotSessionService.supportsActivity` (Task 2). `resolveBotChatActivity` (B Task 4). +- Produces: + - `AidenRemoteBotServiceOptions.chatActivity?: boolean` and `AidenRemoteBotService.supportsChatActivity`. + - The router helpers `botActivityWired(dependencies)` and `botDetailForDevice(detail, device)`. + - `/server` announces `bot-activity-v1`, and `serverCapabilities` lists `bot:activity`, only while wired. + - `POST /device/capabilities` accepts `bot:activity` only while wired (otherwise `not_found`). + +- [ ] **Step 1: Write the failing tests** + +`main/services/aiden-remote-bots.test.ts`: add `chatActivity?: boolean;` to `fixture()`'s options type. It already spreads `...options` into the service. Then add: + +```ts +// Test 1 +test("a phone saves Chat activity with PATCH only where bot-activity-v1 is wired", async () => { + const wired = fixture([bot("bot_1")], { chatActivity: true }); + const before = await wired.service.get("bot_1"); + assert.deepEqual(before.chatActivity, { level: "quiet", wording: "follows" }, "a Bot that never chose reads as the default"); + + const saved = await wired.service.updateIdentity("bot_1", before.revision, { + chatActivity: { level: "summary", wording: "thinking" }, + }); + assert.deepEqual(saved.chatActivity, { level: "summary", wording: "thinking" }); + assert.notEqual(saved.revision, before.revision); + assert.equal(saved.name, before.name, "only the choice changed"); + assert.deepEqual((await wired.service.get("bot_1")).chatActivity, { level: "summary", wording: "thinking" }); + + for (const invalid of [ + { level: "verbose", wording: "follows" }, + { level: "quiet" }, + { level: "quiet", wording: "follows", extra: true }, + ]) { + await assert.rejects( + wired.service.updateIdentity("bot_1", saved.revision, { chatActivity: invalid }), + (error: unknown) => (error as { code?: string }).code === "invalid_request", + JSON.stringify(invalid), + ); + } +}); + +// Test 2 +test("a Mac without bot-activity-v1 neither shows nor accepts chatActivity", async () => { + const older = fixture([bot("bot_1", { chatActivity: { level: "detailed", wording: "follows" } })]); + const detail = await older.service.get("bot_1"); + assert.equal("chatActivity" in detail, false); + await assert.rejects( + older.service.updateIdentity("bot_1", detail.revision, { chatActivity: { level: "summary", wording: "follows" } }), + (error: unknown) => (error as { code?: string }).code === "invalid_request", + ); +}); +``` + +`main/services/aiden-remote-state.test.ts`: + +```ts +// Test 3 +test("bot:activity is an opt-in any paired device may negotiate and keep", async () => { + const state = fixture(); + const issued = await state.registry.issueDevice({ name: "iPhone", type: "iphone", clientVersion: "1.0" }); + const upgraded = await state.registry.upgradeDeviceCapabilities(issued.device.id, ["bot:cards", "bot:activity"]); + assert.deepEqual(upgraded?.capabilities.slice(-2), ["bot:cards", "bot:activity"]); + const authenticated = await state.registry.authenticate(issued.credential); + assert.equal(authenticated?.capabilities.has("bot:activity"), true); + assert.equal(authenticated?.acceptsProgressCapabilities, false, "an opt-in is not a progress grant"); +}); +``` + +`main/services/aiden-remote-router.test.ts`: +1. Add `chatActivity?: boolean;` to `fixture()`'s options type. +2. In its `bots: {` object, add `...(options.chatActivity ? { supportsChatActivity: true } : {}),` and make `get` return: + ```ts + return { + ...botDetail, + id: botId, + access: { ...botAccess, botId }, + ...(options.chatActivity ? { chatActivity: { level: "summary" as const, wording: "follows" as const } } : {}), + }; + ``` +3. In the fake `upgradeDeviceCapabilities`, add `capability !== "bot:activity" &&` before `capability !== "bot:cards"`. +4. Add: + +```ts +function recordingActivitySessions(seen: string[], supportsActivity: boolean) { + const base = recordingRevision27Sessions([], { memory: false, proactive: false }) as unknown as Record; + return { + ...base, + supportsActivity, + session: async (botId: string, options: { activity?: boolean } = {}) => { + seen.push(`session:${options.activity === true ? "activity" : "plain"}`); + return { botId, epoch: "epoch_1", seq: 0, state: "idle", interrupted: false, entries: [], hasOlder: false }; + }, + openEvents: async ( + _deviceId: string, + _botId: string, + response: import("node:http").ServerResponse, + admit: () => void, + options: { activity?: boolean } = {}, + ) => { + seen.push(`events:${options.activity === true ? "activity" : "plain"}`); + admit(); + response.writeHead(204); + response.end(); + }, + } as unknown as NonNullable; +} + +// Test 4 (Review Focus 1) +test("bot-activity-v1 and bot:activity are offered only while activity frames and chatActivity are both wired", async () => { + const headers = { authorization: `Bearer ${"a".repeat(43)}`, "aiden-protocol-version": "1" }; + const negotiate = { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ accepts: ["bot:activity"] }) }; + for (const [sessionsActivity, chatActivity, wired] of [ + [true, true, true], + [true, false, false], + [false, true, false], + ] as const) { + const app = await fixture({ + capabilities: ["server:read", "bot:read", "chat:read"], + acceptsBotCapabilities: true, + deviceType: "iphone", + botSessions: recordingActivitySessions([], sessionsActivity), + chatActivity, + }); + try { + const server = await (await fetch(`${app.base}/server`, { headers })).json(); + assert.equal(server.features.includes("bot-activity-v1"), wired, `${sessionsActivity}/${chatActivity}`); + assert.equal(server.serverCapabilities.includes("bot:activity"), wired); + assert.equal((await fetch(`${app.base}/device/capabilities`, negotiate)).status, wired ? 200 : 404); + } finally { + await app.close(); + } + } +}); + +// Test 5 (Review Focus 1) +test("only a device holding bot:activity is served activity frames and a Bot's chatActivity", async () => { + const headers = { authorization: `Bearer ${"a".repeat(43)}`, "aiden-protocol-version": "1" }; + const seen: string[] = []; + for (const capabilities of [ + ["server:read", "bot:read", "chat:read"], + ["server:read", "bot:read", "chat:read", "bot:activity"], + ] as AidenRemoteCapability[][]) { + const app = await fixture({ + capabilities, + acceptsBotCapabilities: true, + deviceType: "iphone", + botSessions: recordingActivitySessions(seen, true), + chatActivity: true, + }); + try { + await fetch(`${app.base}/bots/bot-1/session`, { headers }); + await fetch(`${app.base}/bots/bot-1/session/events`, { headers }); + const detail = await (await fetch(`${app.base}/bots/bot-1`, { headers })).json(); + seen.push(`detail:${"chatActivity" in detail ? "activity" : "plain"}`); + } finally { + await app.close(); + } + } + assert.deepEqual(seen, [ + "session:plain", "events:plain", "detail:plain", + "session:activity", "events:activity", "detail:activity", + ]); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx tsx --test main/services/aiden-remote-bots.test.ts main/services/aiden-remote-state.test.ts main/services/aiden-remote-router.test.ts` +Expected: FAIL. +- `chatActivity` is absent from detail. +- The PATCH is refused as an unsupported field. +- `bot:activity` is not negotiable (the state upgrade returns null). +- The token is never announced. + +- [ ] **Step 3: The Bot service** + +In `main/services/aiden-remote-bots.ts`: +1. Import `resolveBotChatActivity` from `../../renderer/shared/bots.js`. Add it to the existing import from that module, or add the import. +2. Add to `AidenRemoteBotServiceOptions`: + ```ts + /** Contract revision 30 (`bot-activity-v1`): Bot detail carries `chatActivity` and `PATCH` accepts it. */ + chatActivity?: boolean; + ``` +3. Add to the class: + ```ts + get supportsChatActivity(): boolean { + return this.options.chatActivity === true; + } + ``` +4. In `detail()`, add to the object passed to `parseAidenRemoteBotDetail`, after `visionModelSelection`: + ```ts + ...(this.supportsChatActivity ? { chatActivity: resolveBotChatActivity(bot.chatActivity) } : {}), + ``` +5. In `updateIdentity()`: + - Replace the parser argument with `(value: unknown) => parseAidenRemoteBotIdentityPatchRequest(value, { allowChatActivity: this.supportsChatActivity })`. + - In the `updateBot({ … })` input, add after `avatar: …,`: + ```ts + // Absent keeps the saved choice (slice B ruling 2); only an explicit pick changes it. + ...(parsed.chatActivity ? { chatActivity: parsed.chatActivity } : {}), + ``` + +- [ ] **Step 4: The device state** + +In `main/services/aiden-remote-state.ts`: +- Import `AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES` in place of `AIDEN_REMOTE_BOT_CARD_CAPABILITIES`. +- Replace `isBotCardCapability` with: + +```ts +/** Revision 27/30 opt-ins (`bot:cards`, `bot:activity`): no authority, any device type. */ +function isBotOptInCapability( + value: unknown, +): value is (typeof AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES)[number] { + return ( + typeof value === "string" && + (AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES as readonly string[]).includes(value) + ); +} +``` + +and rename its two call sites in `upgradeDeviceCapabilities` (`!isBotCardCapability(capability)` and `isBotCardCapability(capability)`). + +- [ ] **Step 5: The router** + +In `main/services/aiden-remote-router.ts`: +1. Import `AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES`, `AIDEN_REMOTE_BOT_ACTIVITY_FEATURE` and `type AidenRemoteBotDetail` from the protocol. +2. Dependencies types: + - In `bots?`'s `Partial>`, add `| "supportsChatActivity"`. + - In `botSessions`'s revision-27 `Partial>` list, add `| "supportsActivity"`. +3. Below `advertisedServerCapabilities`, add: + ```ts + /** Revision 30: the activity frames and the Chat activity field ship together, or not at all. */ + function botActivityWired(dependencies: AidenRemoteRouterDependencies): boolean { + return dependencies.botSessions?.supportsActivity === true && dependencies.bots?.supportsChatActivity === true; + } + + /** Bot detail as one device may read it: `chatActivity` only for `bot:activity` holders. */ + function botDetailForDevice( + detail: AidenRemoteBotDetail, + device: AidenRemoteRouterAuthenticatedDevice, + ): AidenRemoteBotDetail { + if (device.capabilities.has("bot:activity")) return detail; + const { chatActivity: _hidden, ...visible } = detail; + return visible; + } + ``` +4. In `advertisedServerCapabilities`, after the `AIDEN_REMOTE_BOT_CARD_CAPABILITIES` spread, add: + ```ts + ...(device.acceptsBotCapabilities === true && botActivityWired(dependencies) + ? AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES + : []), + ``` +5. In `/server`'s `features`, after the `AIDEN_REMOTE_BOT_PROACTIVE_FEATURE` line, add: + ```ts + // Bot chat activity on phones (contract revision 30). + ...(botActivityWired(dependencies) ? [AIDEN_REMOTE_BOT_ACTIVITY_FEATURE] : []), + ``` +6. In `POST /device/capabilities`, the per-capability `if / else if` chain starts with the `bot:cards` branch. Insert a branch between it and `else if (capability === "simulators:control")`, so the chain reads: + ```ts + if ((AIDEN_REMOTE_BOT_CARD_CAPABILITIES as readonly string[]).includes(capability)) { + // (unchanged) An opt-in to two session entry types, not an authority: … + if (!dependencies.botSessions) { + throw new AidenRemoteServiceError("not_found", "Bot sessions are unavailable on this Aiden installation.", 404); + } + } else if ((AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES as readonly string[]).includes(capability)) { + // Revision 30: an opt-in to the activity frames and the detail field, offered only while they are wired. + if (!botActivityWired(dependencies)) { + throw new AidenRemoteServiceError("not_found", "Bot chat activity is unavailable on this Aiden installation.", 404); + } + } else if (capability === "simulators:control") { + // (unchanged) + ``` +7. In `GET /bots/{id}/session` and `GET /bots/{id}/session/events`, change `{ cards: device.capabilities.has("bot:cards") }` to `{ cards: device.capabilities.has("bot:cards"), activity: device.capabilities.has("bot:activity") }`. +8. Wrap the three Bot detail responses: + - `POST /bots`: `botDetailForDevice(await dependencies.bots.create(device.id, key, body), device)` + - `GET /bots/{id}`: `botDetailForDevice(await dependencies.bots.get(botMatch[1]!, device.id), device)` + - `PATCH /bots/{id}`: `botDetailForDevice(await dependencies.bots.updateIdentity(botMatch[1]!, revision, body, device.id), device)` + +- [ ] **Step 6: Wire the Mac** + +In `main/services/aiden-remote-service-main.ts`, in `new AidenRemoteBotService({ … })`, add `chatActivity: true,` before `idempotency,`. + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `npx tsx --test main/services/aiden-remote-bots.test.ts main/services/aiden-remote-state.test.ts main/services/aiden-remote-router.test.ts` +Expected: PASS. + +Run: `npm run test:aiden-remote && npm run type-check && npm run test:ci-policy` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add main/services/aiden-remote-bots.ts main/services/aiden-remote-bots.test.ts main/services/aiden-remote-router.ts main/services/aiden-remote-router.test.ts main/services/aiden-remote-state.ts main/services/aiden-remote-state.test.ts main/services/aiden-remote-service-main.ts +git commit -m "Remote: chatActivity on Bot detail and PATCH; bot-activity-v1 and the bot:activity opt-in" +``` + +--- + +### Task 4: iOS wire models, the `bot:activity` opt-in, the phase table and elapsed time + +**Files:** +- Modify: `ios/AidenOnTheGo/Models/AidenBotSession.swift`, `ios/AidenOnTheGo/Models/AidenBot.swift` +- Modify: `ios/AidenOnTheGo/Networking/AidenRemoteContract.swift`, `ios/AidenOnTheGo/Networking/AidenRemoteClient.swift`, `ios/AidenOnTheGo/Models/AidenInstallation.swift`, `ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift` +- Modify: `ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift` +- Test: `ios/AidenOnTheGoTests/AidenBotSessionTests.swift` (new class `AidenBotActivityContractTests` at the end of the file) + +**Interfaces:** +- Consumes: the Task 1 fixture sections `botActivity` and `botActivityPrivacy`. +- Produces: + - `struct AidenBotSessionActivity { phase: String; app: String?; startedAt: Int }` and `struct AidenBotQueuedMessage { submissionId: String; mode: AidenStreamInputMode; text: String }`. + - `AidenBotSession.activity: AidenBotSessionActivity?` and `AidenBotSession.queued: [AidenBotQueuedMessage]`. + - `AidenBotSessionEvent.Kind.activity(AidenBotSessionActivity?)` and `.queued([AidenBotQueuedMessage])`. + - `AidenBotMessageRequest(text:whenBusy:)`. + - `enum AidenBotChatActivityLevel { quiet, summary, detailed }`, `enum AidenBotChatActivityWording { follows, thinking }` and `struct AidenBotChatActivity { level; wording; static let standard }`. + - `AidenBotDetail.chatActivity` and `AidenBotIdentityPatch(chatActivity:)`. + - `AidenRemoteCapability.botActivity`, `AidenServer.botActivityFeature`, `AidenServer.offersBotActivity` and `AidenServer.supportsBotActivity`. + - `AidenBotHostFeature.activity`. + - `enum AidenBotActivityPhase { struct Display { phase; word; mark }; static let table; static func display(phase:wording:app:) -> Display }`. + - `enum AidenBotElapsed { static func label(startedAt: Int, nowMs: Int) -> String? }`. + +- [ ] **Step 1: Write the failing tests** + +Append to `ios/AidenOnTheGoTests/AidenBotSessionTests.swift`: + +```swift +/// Contract revision 30: the activity wire, read from the fixture every client shares. +final class AidenBotActivityContractTests: XCTestCase { + private func shared() throws -> [String: Any] { + let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "contract", withExtension: "json")) + return try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + } + + private func section(_ name: String) throws -> [String: Any] { + try XCTUnwrap(try shared()[name] as? [String: Any]) + } + + private func decode(_ type: T.Type, _ object: Any) throws -> T { + try AidenRemoteJSONDecoder.decode(type, from: JSONSerialization.data(withJSONObject: object)) + } + + private func encoded(_ value: T) throws -> NSDictionary { + try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(value)) as? NSDictionary) + } + + // Test 1 (Review Focus 3) + func testActivitySessionFramesAndDetailDecodeAsTheMacSendsThem() throws { + let activity = try section("botActivity") + let started = 1_760_450_000_000 + let session = try decode(AidenBotSession.self, try XCTUnwrap(activity["session"])) + XCTAssertEqual(session.activity, AidenBotSessionActivity(phase: "app", app: "Notion", startedAt: started)) + XCTAssertEqual(session.queued.map(\.submissionId), ["18", "19"]) + XCTAssertEqual(session.queued.map(\.mode), [.queue, .steer]) + + let events = try XCTUnwrap(activity["events"] as? [Any]).map { try decode(AidenBotSessionEvent.self, $0) } + XCTAssertEqual(events.map(\.kind), [ + .activity(AidenBotSessionActivity(phase: "searching", startedAt: started)), + .queued([AidenBotQueuedMessage(submissionId: "18", mode: .queue, text: "Also add a shopping list.")]), + .activity(AidenBotSessionActivity(phase: "teleporting", startedAt: started)), + .activity(nil), + ]) + // A newer Mac's phase decodes and reads as Working. + XCTAssertEqual( + AidenBotActivityPhase.display(phase: "teleporting", wording: .follows, app: nil), + AidenBotActivityPhase.display(phase: "working", wording: .follows, app: nil) + ) + + let detail = try decode(AidenBotDetail.self, try XCTUnwrap(activity["detail"])) + XCTAssertEqual(detail.chatActivity, AidenBotChatActivity(level: .summary, wording: .thinking)) + let older = try decode(AidenBotDetail.self, try XCTUnwrap(try shared()["botDetail"])) + XCTAssertNil(older.chatActivity, "a revision-29 detail has no choice to show") + } + + // Test 2 + func testEveryPrivacySampleIsRefused() throws { + let privacy = try section("botActivityPrivacy") + for sample in try XCTUnwrap(privacy["rejectedActivities"] as? [Any]) { + XCTAssertThrowsError(try decode(AidenBotSessionActivity.self, sample), "\(sample)") + } + for sample in try XCTUnwrap(privacy["rejectedQueued"] as? [Any]) { + XCTAssertThrowsError(try decode(AidenBotQueuedMessage.self, sample), "\(sample)") + } + for sample in try XCTUnwrap(privacy["rejectedChatActivity"] as? [Any]) { + XCTAssertThrowsError(try decode(AidenBotChatActivity.self, sample), "\(sample)") + } + } + + // Test 3 + func testRequestsEncodeExactlyTheFixtureBodies() throws { + let activity = try section("botActivity") + let send = try XCTUnwrap((activity["send"] as? [String: Any])?["request"] as? NSDictionary) + XCTAssertEqual(try encoded(try AidenBotMessageRequest(text: "Keep it vegetarian.", whenBusy: .steer)), send) + XCTAssertEqual(try encoded(try AidenBotMessageRequest(text: "Hi")), ["text": "Hi"] as NSDictionary, "no whenBusy unless one was chosen") + let patch = try XCTUnwrap((activity["identityPatch"] as? [String: Any])?["request"] as? NSDictionary) + XCTAssertEqual(try encoded(try AidenBotIdentityPatch(chatActivity: AidenBotChatActivity(level: .detailed, wording: .follows))), patch) + } + + // Test 4 + func testThePhaseTableIsTheOneTheMacDraws() throws { + let activity = try section("botActivity") + let phases = try XCTUnwrap(activity["phases"] as? [[String: String]]) + let keeps = Set(try XCTUnwrap(activity["thinkingKeeps"] as? [String])) + XCTAssertEqual(Set(AidenBotActivityPhase.table.keys), Set(phases.compactMap { $0["id"] })) + for row in phases { + let id = try XCTUnwrap(row["id"]) + let shown = AidenBotActivityPhase.display(phase: id, wording: .follows, app: nil) + XCTAssertEqual(shown.phase, id) + XCTAssertEqual(shown.word, row["word"], id) + XCTAssertEqual(shown.mark.rawValue, row["mark"], id) + XCTAssertEqual( + AidenBotActivityPhase.display(phase: id, wording: .thinking, app: nil).phase, + keeps.contains(id) ? id : "thinking", + id + ) + } + let app = try XCTUnwrap(activity["app"] as? [String: String]) + XCTAssertEqual(AidenBotActivityPhase.display(phase: "app", wording: .follows, app: app["app"]).word, app["word"]) + } + + // Test 5 + func testElapsedTimeMatchesTheMacIncludingAPhoneWhoseClockIsBehind() throws { + let rows = try XCTUnwrap(try section("botActivity")["elapsed"] as? [[String: Any]]) + for row in rows { + let startedAt = try XCTUnwrap(row["startedAt"] as? NSNumber).intValue + let now = try XCTUnwrap(row["now"] as? NSNumber).intValue + XCTAssertEqual(AidenBotElapsed.label(startedAt: startedAt, nowMs: now), row["label"] as? String, "\(row)") + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `xcodebuild test -quiet -project ios/AidenOnTheGo.xcodeproj -scheme AidenOnTheGo -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 17 Pro' CODE_SIGNING_ALLOWED=NO -derivedDataPath /private/tmp/claude-501/aiden-ios-dd -parallel-testing-enabled NO -only-testing:AidenOnTheGoTests/AidenBotActivityContractTests` +Expected: build FAILS. `AidenBotSessionActivity`, `AidenBotChatActivity` and `AidenBotActivityPhase` are undefined. + +- [ ] **Step 3: Session wire types** + +In `ios/AidenOnTheGo/Models/AidenBotSession.swift`: + +1. Directly above `/// `GET /bots/{botId}/session`: the newest entries …`, add: + +```swift +// MARK: Contract revision 30 (`bot-activity-v1`, `bot:activity`) + +/// What a Bot in a running turn is doing (spec §5). Exactly `phase`, `app` +/// and `startedAt` cross the wire; any other key is refused (§11). +struct AidenBotSessionActivity: Codable, Equatable, Sendable { + static let maxAppLength = 40 + + let phase: String + /// The connection's label for the `app` phase. + let app: String? + /// When the turn started: the Mac's clock, milliseconds since 1970. + let startedAt: Int + + init(phase: String, app: String? = nil, startedAt: Int) { + self.phase = phase + self.app = app + self.startedAt = startedAt + } + + init(from decoder: Decoder) throws { + try AidenBotWire.requireOnlyKeys(decoder, allowed: ["phase", "app", "startedAt"]) + let values = try decoder.container(keyedBy: CodingKeys.self) + phase = try values.decode(String.self, forKey: .phase) + guard Self.isPhaseID(phase) else { throw AidenBotContractError.invalidField("activity.phase") } + app = try AidenBotWire.optionalString(values, forKey: .app, maxLength: Self.maxAppLength) + startedAt = try values.decode(Int.self, forKey: .startedAt) + guard (0...AidenRemoteProtocol.maxSafeInteger).contains(startedAt) else { + throw AidenBotContractError.invalidField("activity.startedAt") + } + } + + /// `^[a-z][a-z_]{0,31}$`: a newer Mac's phase still decodes, and reads as Working. + static func isPhaseID(_ value: String) -> Bool { + let scalars = Array(value.unicodeScalars) + guard (1...32).contains(scalars.count), let first = scalars.first, (97...122).contains(first.value) else { + return false + } + return scalars.allSatisfy { (97...122).contains($0.value) || $0 == "_" } + } + + private enum CodingKeys: String, CodingKey { case phase, app, startedAt } +} + +/// A message waiting for the running turn: `steer` joins it, `queue` runs after it. +struct AidenBotQueuedMessage: Codable, Equatable, Sendable { + static let maxCount = 20 + + let submissionId: String + let mode: AidenStreamInputMode + let text: String + + init(submissionId: String, mode: AidenStreamInputMode, text: String) { + self.submissionId = submissionId + self.mode = mode + self.text = text + } + + init(from decoder: Decoder) throws { + try AidenBotWire.requireOnlyKeys(decoder, allowed: ["submissionId", "mode", "text"]) + let values = try decoder.container(keyedBy: CodingKeys.self) + submissionId = try AidenBotWire.requiredString( + values, + forKey: .submissionId, + maxLength: AidenBotSessionWire.maxSubmissionIDLength + ) + guard submissionId.unicodeScalars.allSatisfy({ scalar in + switch scalar.value { + case 48...57, 65...90, 97...122, 46, 95, 58, 45: true + default: false + } + }) else { throw AidenBotContractError.invalidField("queued.submissionId") } + mode = try values.decode(AidenStreamInputMode.self, forKey: .mode) + text = try AidenBotWire.requiredString(values, forKey: .text, maxLength: AidenBotSessionWire.maxMessageLength) + } + + /// At most 20, each id once. + static func validate(_ list: [Self]) throws { + guard list.count <= maxCount, Set(list.map(\.submissionId)).count == list.count else { + throw AidenBotContractError.invalidField("queued") + } + } + + private enum CodingKeys: String, CodingKey { case submissionId, mode, text } +} +``` + +2. In `struct AidenBotSession`: + - Add the stored properties after `approval`: + ```swift + /// Revision 30 (`bot:activity`): what the running turn is doing, or nil. + let activity: AidenBotSessionActivity? + /// Revision 30 (`bot:activity`): the person's messages waiting for the turn. + let queued: [AidenBotQueuedMessage] + ``` + - Add `activity: AidenBotSessionActivity? = nil, queued: [AidenBotQueuedMessage] = []` as the last memberwise `init` parameters, and assign them. + - In `init(from:)`, add `"activity", "queued"` to `requireOnlyKeys`'s `allowed` list. After `approval = …`, add: + ```swift + activity = try values.decodeIfPresent(AidenBotSessionActivity.self, forKey: .activity) + if values.contains(.queued) { + let list = try values.decode([AidenBotQueuedMessage].self, forKey: .queued) + try AidenBotQueuedMessage.validate(list) + queued = list + } else { + queued = [] + } + ``` + - In `encode(to:)`, add after `approval`: + ```swift + try values.encodeIfPresent(activity, forKey: .activity) + if !queued.isEmpty { try values.encode(queued, forKey: .queued) } + ``` + - Change `CodingKeys` to `case botId, epoch, seq, partial, entries, hasOlder, question, approval, activity, queued`. +3. In `struct AidenBotSessionEvent`: + - Add to `Kind` before `closed`: + ```swift + /// Revision 30: what the running turn is doing now, or nil when none runs. + case activity(AidenBotSessionActivity?) + /// Revision 30: the person's messages still waiting for the turn. + case queued([AidenBotQueuedMessage]) + ``` + - In `wireType`, add `case .activity: "activity"` and `case .queued: "queued"`. + - In `init(from:)`'s switch, before `case "closed":`, add: + ```swift + case "activity": + try AidenBotWire.requireOnlyKeys(payload, allowed: ["activity"]) + guard fields.contains(.activity) else { throw AidenBotContractError.invalidField("activity") } + kind = .activity(try fields.decode(AidenBotSessionActivity?.self, forKey: .activity)) + case "queued": + try AidenBotWire.requireOnlyKeys(payload, allowed: ["queued"]) + let list = try fields.decode([AidenBotQueuedMessage].self, forKey: .queued) + try AidenBotQueuedMessage.validate(list) + kind = .queued(list) + ``` + - In `encode(to:)`'s switch, add `case let .activity(activity): try fields.encode(activity, forKey: .activity)` and `case let .queued(list): try fields.encode(list, forKey: .queued)`. + - Change `PayloadKeys` to `case session, text, entry, question, approval, activity, queued`. + - If the compiler reports another exhaustive `switch` over `AidenBotSessionEvent.Kind` (apart from `AidenBotSessionModel.apply`, which Task 5 updates), add `case .activity, .queued: break` there. +4. Replace `struct AidenBotMessageRequest` with: + +```swift +/// `POST /bots/{botId}/messages` body. +struct AidenBotMessageRequest: Codable, Equatable, Sendable { + let text: String + /// Revision 30 (`bot-activity-v1`): Steer joins the running turn; Queue runs after it. + let whenBusy: AidenStreamInputMode? + + init(text: String, whenBusy: AidenStreamInputMode? = nil) throws { + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + text.unicodeScalars.count <= AidenBotSessionWire.maxMessageLength else { + throw AidenBotContractError.invalidField("text") + } + self.text = text + self.whenBusy = whenBusy + } + + init(from decoder: Decoder) throws { + try AidenBotWire.requireOnlyKeys(decoder, allowed: ["text", "whenBusy"]) + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + text: try values.decode(String.self, forKey: .text), + whenBusy: try values.decodeIfPresent(AidenStreamInputMode.self, forKey: .whenBusy) + ) + } + + private enum CodingKeys: String, CodingKey { case text, whenBusy } +} +``` + +- [ ] **Step 4: Chat activity on Bot detail and the identity patch** + +In `ios/AidenOnTheGo/Models/AidenBot.swift`: + +1. Below `struct AidenBotModelSelection`, add: + +```swift +/// A Bot's Chat activity choice (spec §7, contract revision 30). +enum AidenBotChatActivityLevel: String, Codable, CaseIterable, Sendable { + case quiet, summary, detailed +} + +enum AidenBotChatActivityWording: String, Codable, CaseIterable, Sendable { + case follows, thinking +} + +struct AidenBotChatActivity: Codable, Equatable, Sendable { + var level: AidenBotChatActivityLevel + var wording: AidenBotChatActivityWording + + /// What a Bot that never chose shows: Quiet, Follows the work. + static let standard = AidenBotChatActivity(level: .quiet, wording: .follows) + + init(level: AidenBotChatActivityLevel, wording: AidenBotChatActivityWording) { + self.level = level + self.wording = wording + } + + init(from decoder: Decoder) throws { + try AidenBotWire.requireOnlyKeys(decoder, allowed: ["level", "wording"]) + let values = try decoder.container(keyedBy: CodingKeys.self) + level = try values.decode(AidenBotChatActivityLevel.self, forKey: .level) + wording = try values.decode(AidenBotChatActivityWording.self, forKey: .wording) + } + + private enum CodingKeys: String, CodingKey { case level, wording } +} +``` + +2. In `struct AidenBotDetail`: + - Add the property `/// Revision 30 (`bot:activity`): the Bot's Chat activity choice. let chatActivity: AidenBotChatActivity?`. + - In `init(from:)`, after `visionModelSelection = …`, add: + ```swift + chatActivity = try AidenBotWire.optional( + AidenBotChatActivity.self, + from: values, + forKey: .chatActivity + ) + ``` +3. In `struct AidenBotIdentityPatch`: + - Add the property `let chatActivity: AidenBotChatActivity?`. + - Add the memberwise parameter `chatActivity: AidenBotChatActivity? = nil` after `avatar`, assigned as `self.chatActivity = chatActivity`. + - Extend both "empty identity patch" guards with `|| chatActivity != nil`. + - In `init(from:)`, add `chatActivity = try values.decodeIfPresent(AidenBotChatActivity.self, forKey: .chatActivity)` before the final guard. + - Change `CodingKeys` to `case name, purpose, instructions, avatar, chatActivity`. + +- [ ] **Step 5: The opt-in and the host flags** + +1. `ios/AidenOnTheGo/Networking/AidenRemoteContract.swift`, in `AidenRemoteCapability`: + - In the `phoneNegotiable` doc comment, add: "Contract revision 30 adds `bot:activity`, which only opts the device into the activity and queued session frames and Bot detail `chatActivity`." + - Add `Self(rawValue: "bot:activity")` to the list after `Self(rawValue: "bot:cards")`. + - Below `static let botCards`, add: + ```swift + /// Revision 30: the device receives activity and queued frames and Bot + /// detail `chatActivity`. It grants no authority. + static let botActivity = Self(rawValue: "bot:activity") + ``` +2. `ios/AidenOnTheGo/Networking/AidenRemoteClient.swift`: + - In `AidenServer`, below `static let mobileSimulatorsFeature`, add `static let botActivityFeature = "bot-activity-v1"`. + - Below `var supportsBotCards`, add: + ```swift + /// The Mac offers `bot:activity` (revision 30), so this device may ask for it. + var offersBotActivity: Bool { + serverCapabilities?.contains(.botActivity) == true + } + + /// The Mac announces `bot-activity-v1` and this device holds `bot:activity`: + /// show the status line, Steer and Queue, Queued bubbles and Chat activity. + var supportsBotActivity: Bool { + features.contains(Self.botActivityFeature) && capabilities.contains(.botActivity) + } + ``` + - In `updateDeviceCapabilities`'s `allowed` set, add `.botActivity` after `.botCards`. +3. `ios/AidenOnTheGo/Models/AidenInstallation.swift`: in `updateNegotiatedDeviceCapabilities`'s `progress` set, add `.botActivity` after `.botCards`. +4. `ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift`: in `negotiateProgressCapabilitiesIfNeeded`, after `if server.supportsBotCards { requested.append(.botCards) }`, add `if server.offersBotActivity { requested.append(.botActivity) }`. + +- [ ] **Step 6: The phase table and elapsed time** + +In `ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift`: + +1. Add to `AidenBotHostFeature`: + ```swift + /// Contract revision 30: the status line, Steer and Queue, Queued bubbles and Chat activity. + static let activity = "bot-activity-v1" + ``` +2. Add to `AidenBotSessionCopy`: `static let queued = "Queued"`. +3. Below `enum AidenBotSessionCopy`, add: + +```swift +/// The status line's words and marks (spec §5). Kept in step with +/// `renderer/shared/bot-activity.ts` by the shared fixture's `botActivity.phases`. +enum AidenBotActivityPhase { + struct Display: Equatable { + let phase: String + let word: String + let mark: AidenActivityMarkKind + } + + static let table: [String: (word: String, mark: AidenActivityMarkKind)] = [ + "preparing": ("Getting ready", .bounce), + "thinking": ("Thinking", .triStep), + "reading": ("Reading", .scanGrid), + "photo": ("Looking at your photo", .scanGrid), + "searching": ("Searching the web", .scanGrid), + "command": ("Running a command", .quadShuffle), + "editing": ("Editing files", .quadShuffle), + "app": ("Using an app", .quadShuffle), + "helper": ("Asking a helper", .quadShuffle), + "computer": ("Using your Mac", .quadShuffle), + "remembering": ("Remembering", .compose), + "routine": ("Setting up a routine", .triStep), + "working": ("Working", .quadShuffle), + "writing": ("Writing", .compose), + "waiting": ("Waiting for you", .glance), + "tidying": ("Tidying up its notes", .bounce), + "stopping": ("Stopping", .bounce), + "resuming": ("Picking up where it left off", .bounce), + "reconnecting": ("Reconnecting to your Mac", .bounce), + ] + + /// These change what the person should do, so Always "Thinking" keeps them. + static let keepsItsWord: Set = ["waiting", "stopping", "reconnecting"] + + /// What the line shows for any phase id: an id this build doesn't know reads as Working. + static func display(phase: String, wording: AidenBotChatActivityWording, app: String?) -> Display { + let known = table[phase] == nil ? "working" : phase + let shown = wording == .thinking && !keepsItsWord.contains(known) ? "thinking" : known + let entry = table[shown] ?? ("Working", .quadShuffle) + if shown == "app", let app, !app.isEmpty { + return Display(phase: shown, word: "Using \(app)", mark: entry.mark) + } + return Display(phase: shown, word: entry.word, mark: entry.mark) + } +} + +/// Elapsed time on the status line (§5.2): from the Mac's `startedAt`, after 20 seconds. +enum AidenBotElapsed { + static let showAfterMs = 20_000 + + /// Nil before 20 seconds, and while this phone's clock reads behind the Mac's. + static func label(startedAt: Int, nowMs: Int) -> String? { + let elapsed = nowMs - startedAt + guard elapsed >= showAfterMs else { return nil } + return duration(ms: elapsed) + } + + /// "18s", "1m 12s", "2m", "1h 2m", the Mac's wording. + static func duration(ms: Int) -> String { + let seconds = max(1, ms / 1_000) + if seconds < 60 { return "\(seconds)s" } + let minutes = seconds / 60 + let rest = seconds % 60 + if minutes < 60 { return rest > 0 ? "\(minutes)m \(rest)s" : "\(minutes)m" } + let hours = minutes / 60 + let remainder = minutes % 60 + return remainder > 0 ? "\(hours)h \(remainder)m" : "\(hours)h" + } +} +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run the Step 2 command again. +Expected: PASS (5 tests). + +Run the existing Bot suites to check nothing else moved: +`xcodebuild test -quiet -project ios/AidenOnTheGo.xcodeproj -scheme AidenOnTheGo -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 17 Pro' CODE_SIGNING_ALLOWED=NO -derivedDataPath /private/tmp/claude-501/aiden-ios-dd -parallel-testing-enabled NO -only-testing:AidenOnTheGoTests/AidenBotSessionTests -only-testing:AidenOnTheGoTests/AidenBotContractTests -only-testing:AidenOnTheGoTests/AidenRemotePhase0Tests` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add ios/AidenOnTheGo/Models/AidenBotSession.swift ios/AidenOnTheGo/Models/AidenBot.swift ios/AidenOnTheGo/Networking/AidenRemoteContract.swift ios/AidenOnTheGo/Networking/AidenRemoteClient.swift ios/AidenOnTheGo/Models/AidenInstallation.swift ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift ios/AidenOnTheGoTests/AidenBotSessionTests.swift +git commit -m "iOS Bots: revision 30 activity wire, bot:activity opt-in, shared status words" +``` + +--- + +### Task 5: iOS session model: status line, Reconnecting, Queued and Steer/Queue + +**Files:** +- Modify: `ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift` +- Test: `ios/AidenOnTheGoTests/AidenBotSessionTests.swift` (extend `FakeBotSessionTransport`; new class `AidenBotSessionActivityTests`) + +**Interfaces:** +- Consumes: Task 4's types, `AidenBotActivityPhase` and `AidenBotElapsed`. +- Produces on `AidenBotSessionModel`: + - `var activitySupported: Bool` and `var wording: AidenBotChatActivityWording`. + - `private(set) var activity`, `queued` and `isStreamDown`. + - `var offersBusyModes: Bool`. + - `func statusLine(nowMs: Int) -> AidenBotStatusLine?`. + - `func send(_:whenBusy:) async -> Bool`. +- Also produces `struct AidenBotStatusLine { display; elapsed; accessibilityLabel }` and `struct AidenBotStatusAnnouncer { mutating func offer(_:at:) -> (announce: String?, retryAfter: TimeInterval?) }`. + +- [ ] **Step 1: Write the failing tests** + +In `AidenBotSessionTests.swift`, change `FakeBotSessionTransport`: +- Replace `private var _streams: [[AidenBotSessionEvent]] = []` with `private var _streams: [(events: [AidenBotSessionEvent], staysOpen: Bool)] = []`. +- Replace `queueStream` with `func queueStream(_ events: [AidenBotSessionEvent], staysOpen: Bool = false) { locked { _streams.append((events, staysOpen)) } }`. +- Add `private var _sendModes: [AidenStreamInputMode?] = []` and `var sendModes: [AidenStreamInputMode?] { locked { _sendModes } }`. +- In `sendBotMessage`, add `_sendModes.append(request.whenBusy)` beside `_sendTexts.append(request.text)`. +- Replace `botSessionEvents` with: + +```swift + func botSessionEvents(botId: String) -> AsyncThrowingStream { + let next = locked { () -> (events: [AidenBotSessionEvent], staysOpen: Bool) in + _streamRequests += 1 + return _streams.isEmpty ? ([], false) : _streams.removeFirst() + } + return AsyncThrowingStream { continuation in + for event in next.events { continuation.yield(event) } + // A stream that stays open ends only when its listener is cancelled. + if next.staysOpen { return } + if next.events.isEmpty { + continuation.finish(throwing: URLError(.networkConnectionLost)) + } else { + continuation.finish() + } + } + } +``` + +Append: + +```swift +@MainActor +final class AidenBotSessionActivityTests: XCTestCase { + private let botID = "bot_fixture_01" + private let started = 1_760_450_000_000 + + private func running( + _ activity: AidenBotSessionActivity?, + epoch: String = "epoch_a", + seq: Int = 4, + partial: String? = nil + ) throws -> AidenBotSession { + AidenBotSession( + botId: botID, + epoch: epoch, + seq: seq, + stateView: try AidenBotSessionStateView(state: .running), + partial: partial, + entries: [], + hasOlder: false, + activity: activity + ) + } + + private func event(epoch: String = "epoch_a", seq: Int, _ kind: AidenBotSessionEvent.Kind) -> AidenBotSessionEvent { + AidenBotSessionEvent(botId: botID, epoch: epoch, seq: seq, kind: kind) + } + + private func loaded( + _ transport: FakeBotSessionTransport, + _ session: AidenBotSession, + supported: Bool = true + ) async -> AidenBotSessionModel { + transport.queueSession(.success(session)) + let model = AidenBotSessionModel(botID: botID, transport: transport) + model.activitySupported = supported + await model.load() + return model + } + + private func waitUntil(_ condition: () -> Bool, what: String) async throws { + let deadline = Date().addingTimeInterval(5) + while !condition() { + guard Date() < deadline else { return XCTFail("Timed out waiting for \(what)") } + try await Task.sleep(for: .milliseconds(20)) + } + } + + // Test 1 + func testTheLineShowsUntilTheReplyStreamsAndFollowsTheChosenWording() async throws { + let model = await loaded(FakeBotSessionTransport(), try running(AidenBotSessionActivity(phase: "reading", startedAt: started))) + XCTAssertEqual(model.statusLine(nowMs: started + 1_000)?.display.word, "Reading") + XCTAssertEqual(model.statusLine(nowMs: started + 1_000)?.display.mark, .scanGrid) + model.wording = .thinking + XCTAssertEqual(model.statusLine(nowMs: started + 1_000)?.display.word, "Thinking") + _ = await model.apply(event(seq: 5, .activity(AidenBotSessionActivity(phase: "waiting", startedAt: started)))) + XCTAssertEqual(model.statusLine(nowMs: started + 1_000)?.display.word, "Waiting for you", "a card that needs the person keeps its word") + _ = await model.apply(event(seq: 6, .partial("Here is"))) + XCTAssertNil(model.statusLine(nowMs: started + 1_000), "the reply's words are the signal") + _ = await model.apply(event(seq: 7, .state(try AidenBotSessionStateView(state: .idle)))) + _ = await model.apply(event(seq: 8, .partial(""))) + XCTAssertNil(model.statusLine(nowMs: started + 1_000), "nothing shows once the turn ended") + } + + // Test 2 (Review Focus 3) + func testAnUnknownPhaseReadsAsWorkingAndElapsedTimeAppearsAfterTwentySeconds() async throws { + let model = await loaded(FakeBotSessionTransport(), try running(AidenBotSessionActivity(phase: "browsing", startedAt: started))) + let early = try XCTUnwrap(model.statusLine(nowMs: started + 19_000)) + XCTAssertEqual(early.display.word, "Working") + XCTAssertEqual(early.display.mark, .quadShuffle) + XCTAssertNil(early.elapsed) + let later = try XCTUnwrap(model.statusLine(nowMs: started + 72_000)) + XCTAssertEqual(later.elapsed, "1m 12s") + XCTAssertEqual(later.accessibilityLabel, "Working, 1m 12s") + } + + // Test 3 + func testBeforeTheFirstActivityFrameItSaysGettingReadyAndOlderMacsShowNothing() async throws { + let fresh = await loaded(FakeBotSessionTransport(), try running(nil)) + XCTAssertEqual(fresh.statusLine(nowMs: started)?.display.word, "Getting ready") + let older = await loaded( + FakeBotSessionTransport(), + try running(AidenBotSessionActivity(phase: "reading", startedAt: started)), + supported: false + ) + XCTAssertNil(older.statusLine(nowMs: started)) + XCTAssertFalse(older.offersBusyModes) + } + + // Test 4 (Review Focus 2) + func testADroppedStreamReadsReconnectingUntilTheNextConnection() async throws { + let transport = FakeBotSessionTransport() + transport.queueStream([]) // the first connection drops at once + transport.queueStream( + [event(epoch: "epoch_b", seq: 1, .snapshot(try running(AidenBotSessionActivity(phase: "searching", startedAt: started), epoch: "epoch_b", seq: 1)))], + staysOpen: true + ) + let model = await loaded(transport, try running(AidenBotSessionActivity(phase: "reading", startedAt: started))) + let listener = Task { await model.listen() } + + try await waitUntil({ model.isStreamDown }, what: "the drop") + XCTAssertEqual(model.statusLine(nowMs: started + 1_000)?.display.word, "Reconnecting to your Mac") + try await waitUntil({ !model.isStreamDown && model.epoch == "epoch_b" }, what: "the next connection") + XCTAssertEqual(model.statusLine(nowMs: started + 1_000)?.display.word, "Searching the web") + + listener.cancel() + await listener.value + } + + // Test 5 + func testSteerAndQueueGoOutOnlyWhileTheBotWorksOnAMacThatOffersThem() async throws { + let transport = FakeBotSessionTransport() + let supported = await loaded(transport, try running(nil)) + XCTAssertTrue(supported.offersBusyModes) + XCTAssertTrue(await supported.send("Keep it vegetarian.", whenBusy: .steer)) + let older = await loaded(transport, try running(nil), supported: false) + XCTAssertTrue(await older.send("Also a list.", whenBusy: .queue)) + XCTAssertEqual(transport.sendModes, [.steer, nil], "an older Mac would refuse whenBusy") + } + + // Test 6 + func testQueuedMessagesFollowTheirFramesAndAFreshSnapshotReplacesThem() async throws { + let waiting = [AidenBotQueuedMessage(submissionId: "18", mode: .queue, text: "Also add a shopping list.")] + let model = await loaded(FakeBotSessionTransport(), try running(nil)) + _ = await model.apply(event(seq: 5, .queued(waiting))) + XCTAssertEqual(model.queued, waiting) + _ = await model.apply(event(seq: 6, .snapshot(try running(nil, seq: 6)))) + XCTAssertEqual(model.queued, []) + } + + // Test 7 + func testScreenReadersHearAStatusChangeAtMostEveryTwoSeconds() { + var announcer = AidenBotStatusAnnouncer() + XCTAssertEqual(announcer.offer("Reading", at: 0).announce, "Reading") + XCTAssertNil(announcer.offer("Reading", at: 0.5).announce, "the same word is not repeated") + let early = announcer.offer("Searching the web", at: 1) + XCTAssertNil(early.announce) + XCTAssertEqual(early.retryAfter ?? 0, 1, accuracy: 0.001) + XCTAssertEqual(announcer.offer("Searching the web", at: 2).announce, "Searching the web") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `xcodebuild test … -only-testing:AidenOnTheGoTests/AidenBotSessionActivityTests` (the Task 4 command with this target). +Expected: build FAILS. `activitySupported`, `statusLine(nowMs:)` and `AidenBotStatusAnnouncer` are undefined. + +- [ ] **Step 3: Implement** + +In `AidenBotSessionModel.swift`: + +1. Below `enum AidenBotElapsed`, add: + +```swift +/// What the Bot chat's status line shows (spec §12). +struct AidenBotStatusLine: Equatable { + let display: AidenBotActivityPhase.Display + /// "1m 12s" after 20 seconds, else nil. + let elapsed: String? + + var accessibilityLabel: String { + elapsed.map { "\(display.word), \($0)" } ?? display.word + } +} + +/// Screen readers hear a status change at most once every 2 seconds (§14). +struct AidenBotStatusAnnouncer { + static let gap: TimeInterval = 2 + private var current: (text: String, at: TimeInterval)? + + /// The text to announce now, or nil. `retryAfter` says when to offer the newest text again. + mutating func offer(_ text: String, at now: TimeInterval) -> (announce: String?, retryAfter: TimeInterval?) { + if let current, current.text == text { return (nil, nil) } + if let current, now - current.at < Self.gap { return (nil, Self.gap - (now - current.at)) } + current = (text, now) + return (text, nil) + } +} +``` + +2. In `AidenBotSessionModel`, add after `respondingProposals`: + +```swift + /// Revision 30: the Mac announces `bot-activity-v1` and this phone holds `bot:activity`. + var activitySupported = false + /// The Bot's Chat activity wording, from its detail. + var wording: AidenBotChatActivityWording = .follows + /// What the running turn is doing (revision 30), or nil. + private(set) var activity: AidenBotSessionActivity? + /// The person's messages waiting for the running turn (revision 30). + private(set) var queued: [AidenBotQueuedMessage] = [] + /// The session stream dropped and is reconnecting. + private(set) var isStreamDown = false +``` + + Change `retainedMessage` to `@ObservationIgnored private var retainedMessage: (text: String, whenBusy: AidenStreamInputMode?, key: UUID)?`. + +3. In `listen()`: + - Inside the `for try await` loop, add `isStreamDown = false` after `failures = 0`. + - After `guard !Task.isCancelled else { return }`, add: + ```swift + // Closed or dropped: while a turn runs the line says "Reconnecting to your Mac". + isStreamDown = true + ``` +4. In `apply(_ event:)`'s switch, add before `case .closed:`: + ```swift + case let .activity(next): + activity = next + case let .queued(next): + queued = next + ``` +5. In `apply(snapshot:authoritative:)`, add `activity = session.activity` and `queued = session.queued` after `approval = session.approval`. +6. Add below `canSend`: + +```swift + /// Steer and Queue while the Bot works need the Mac's `bot-activity-v1`. + var offersBusyModes: Bool { activitySupported && isRunning } + + /// The status line now (spec §12), or nil when it is hidden. + func statusLine(nowMs: Int) -> AidenBotStatusLine? { + guard activitySupported, isRunning else { return nil } + let elapsed = activity.flatMap { AidenBotElapsed.label(startedAt: $0.startedAt, nowMs: nowMs) } + if isStreamDown { + return AidenBotStatusLine( + display: AidenBotActivityPhase.display(phase: "reconnecting", wording: wording, app: nil), + elapsed: elapsed + ) + } + // While the reply's words stream, the words are the signal (§5.2). + guard (partial ?? "").isEmpty else { return nil } + guard let activity else { + // Sent, and the first activity frame is on its way. + return AidenBotStatusLine( + display: AidenBotActivityPhase.display(phase: "preparing", wording: wording, app: nil), + elapsed: nil + ) + } + return AidenBotStatusLine( + display: AidenBotActivityPhase.display(phase: activity.phase, wording: wording, app: activity.app), + elapsed: elapsed + ) + } +``` + +7. Replace the start of `send(_:)`, through the end of the key selection, with: + +```swift + /// Sends one message. A Bot that needs an AI model sends nothing. While it + /// works, on a Mac offering `bot-activity-v1`, `whenBusy` says Steer or Queue. + @discardableResult + func send(_ rawText: String, whenBusy mode: AidenStreamInputMode? = nil) async -> Bool { + let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + let whenBusy = offersBusyModes ? mode : nil + guard canSend, let request = try? AidenBotMessageRequest(text: text, whenBusy: whenBusy) else { return false } + let key: UUID + if let retainedMessage, retainedMessage.text == text, retainedMessage.whenBusy == whenBusy { + key = retainedMessage.key + } else { + key = UUID() + retainedMessage = (text, whenBusy, key) + } +``` + + The rest of `send` is unchanged. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `xcodebuild test … -only-testing:AidenOnTheGoTests/AidenBotSessionActivityTests -only-testing:AidenOnTheGoTests/AidenBotSessionTests` +Expected: PASS. That is the 7 new tests plus the existing session tests, including `testClosedEndsTheConnectionAndListenReconnectsForANewSnapshot`. + +- [ ] **Step 5: Commit** + +```bash +git add ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift ios/AidenOnTheGoTests/AidenBotSessionTests.swift +git commit -m "iOS Bots: status line state, Reconnecting, queued messages and Steer/Queue in the session model" +``` + +--- + +### Task 6: iOS chat: the status line, the busy pill, Queued bubbles and glancing faces + +**Files:** +- Modify: `ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift`, `ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift`, `ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift`, `ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift` +- Test: `ios/AidenOnTheGoTests/AidenBotSessionTests.swift` (new class `AidenBotFaceGlanceTests`) + +**Interfaces:** +- Consumes: Task 5's model API. `AidenActivityMark`, `AidenActivityMarkSpec.make(_:)` and `AidenActivityMarkEvaluator.pose(of:atTime:)`. `AidenStreamInputMode.busyModes`, `busyLabel`, `busyDetail` and `busyActionLabel`. +- Produces: + - `struct AidenBotFaceGlancePose { dx: Double; blink: Double; static let still }` and `func aidenBotFaceGlancePose(at:) -> AidenBotFaceGlancePose`. + - The new parameters `AidenBotSemanticAvatarView(…, working:)` and `AidenBotCanonicalAvatarView(…, working:)`. + - `struct AidenBotStatusLineView`. + +- [ ] **Step 1: Write the failing test** + +Append to `AidenBotSessionTests.swift`: + +```swift +/// A working Bot's face follows the shared `glance` keyframes (docs/activity-marks.md). +final class AidenBotFaceGlanceTests: XCTestCase { + func testTheEyesLookLeftThenRightAndBlinkOnTheGlanceTimeline() { + XCTAssertEqual(aidenBotFaceGlancePose(at: 0), .still, "t = 0 is the still pose Reduce Motion shows") + // 1.28 s is 32% of the 4 s period: held at translateX −2.6 of 24. + XCTAssertEqual(aidenBotFaceGlancePose(at: 1.28).dx, -2.6 / 24, accuracy: 0.0005) + // 2.4 s is 60%: held at +2.6. + XCTAssertEqual(aidenBotFaceGlancePose(at: 2.4).dx, 2.6 / 24, accuracy: 0.0005) + // 3.6 s is 90%: the blink's lowest point. + XCTAssertEqual(aidenBotFaceGlancePose(at: 3.6).blink, 0.1, accuracy: 0.0005) + XCTAssertEqual(aidenBotFaceGlancePose(at: 4).dx, 0, accuracy: 0.0005, "the timeline loops") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `xcodebuild test … -only-testing:AidenOnTheGoTests/AidenBotFaceGlanceTests` +Expected: build FAILS. `aidenBotFaceGlancePose` is undefined. + +- [ ] **Step 3: The glancing face** + +In `ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift`: + +1. Above `struct AidenBotSemanticAvatarView`, add: + +```swift +/// The canonical face's glance while its Bot works: the eyes look left and +/// right and blink, on the shared `glance` keyframes. `dx` is a fraction of the +/// avatar's size; `blink` scales the eyes' height. +struct AidenBotFaceGlancePose: Equatable { + var dx: Double + var blink: Double + + static let still = AidenBotFaceGlancePose(dx: 0, blink: 1) +} + +private let aidenBotGlanceSpec = AidenActivityMarkSpec.make(.glance) + +func aidenBotFaceGlancePose(at time: TimeInterval) -> AidenBotFaceGlancePose { + let look = AidenActivityMarkEvaluator.pose(of: aidenBotGlanceSpec.groups[0].tracks, atTime: time) + let eye = AidenActivityMarkEvaluator.pose(of: aidenBotGlanceSpec.shapes[0].tracks, atTime: time) + // The mark's view box is 24 wide. + return AidenBotFaceGlancePose(dx: look.translateX / 24, blink: eye.scaleY) +} +``` + +2. In `AidenBotSemanticAvatarView`: + - Add `var working = false`, `@Environment(\.accessibilityReduceMotion) private var systemReduceMotion` and `@Environment(\.aidenReduceMotion) private var appReduceMotion`. + - Change `AidenBotEyeMark(size: size)` to `AidenBotEyeMark(size: size, glancing: working && !systemReduceMotion && !appReduceMotion)`. +3. Replace `AidenBotEyeMark` with: + +```swift +/// The one eye mark every Bot shares: two short, slightly tilted strokes. +/// While its Bot works they glance; otherwise (and under Reduce Motion) they hold still. +private struct AidenBotEyeMark: View { + let size: CGFloat + var glancing = false + + var body: some View { + TimelineView(.animation(paused: !glancing)) { context in + let pose = glancing ? aidenBotFaceGlancePose(at: context.date.timeIntervalSinceReferenceDate) : .still + HStack(spacing: size * 0.09) { + Capsule() + .frame(width: size * 0.09, height: size * 0.2) + .scaleEffect(x: 1, y: pose.blink) + Capsule() + .frame(width: size * 0.09, height: size * 0.2) + .scaleEffect(x: 1, y: pose.blink) + } + .foregroundStyle(Color(aidenRGB: aidenBotAvatarFaceRGB)) + .offset(x: pose.dx * size) + .rotationEffect(.degrees(-12)) + .offset(x: size * 0.08, y: -size * 0.02) + } + .accessibilityHidden(true) + } +} +``` + +4. In `AidenBotGeneratedAvatarLifecycle.swift`'s `AidenBotCanonicalAvatarView`: + - Add `var working = false` and `@Environment(\.aidenPalette) private var palette`. + - Pass `working: working` to the inner `AidenBotSemanticAvatarView`. + - After the `if let canonicalImage { … }` block inside the `ZStack`, add: + ```swift + // A photo can't glance; a small glance mark says the Bot is working. + if working, canonicalImage != nil { + AidenActivityMark(mark: .glance, size: size * 0.32, color: palette.foreground) + .padding(size * 0.04) + .background(palette.canvas, in: Circle()) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + } + ``` +5. In `AidenBotsHomeView.swift`'s `botRow`, pass `working: row.isWorking` to `AidenBotCanonicalAvatarView`, and delete the `.overlay(alignment: .bottomTrailing) { if row.isWorking { Circle()… } }` green dot. The row's accessibility label keeps "Working". + +- [ ] **Step 4: The status line, the busy pill and Queued bubbles** + +In `AidenBotSessionChatView.swift`: + +1. Add the state `@State private var busyMode: AidenStreamInputMode = .queue`. +2. In `start()`, after `self.model = model`, add `model.activitySupported = coordinator.server?.supportsBotActivity == true`. +3. In `loadSummary()`, inside the `if let detail = …` block, add `model?.wording = detail.chatActivity?.wording ?? .follows`. +4. Add to the view's modifiers, after `.task(id: filesLookupKey) { … }`: + ```swift + // Chat activity is changed in Advanced; reread the Bot when it closes. + .onChange(of: isShowingAdvanced) { _, showing in + if !showing { Task { await loadSummary() } } + } + .onChange(of: model?.isRunning == true) { _, running in + if !running { busyMode = .queue } + } + ``` +5. In `transcript`, after the `if let partial = model.partial { … }` block, add: + ```swift + ForEach(model.queued, id: \.submissionId) { message in + queuedBubble(message) + } + AidenBotStatusLineView(model: model) + .id("status") + ``` + Also add `.onChange(of: model.queued.count) { _, _ in proxy.scrollTo("bottom", anchor: .bottom) }` next to the other `onChange`s. +6. Add to the view: + +```swift + private func queuedBubble(_ message: AidenBotQueuedMessage) -> some View { + VStack(alignment: .trailing, spacing: 4) { + Text(message.text) + .foregroundStyle(palette.foreground) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(palette.raised, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + // A queued message waits for the reply; a steer has already joined it. + .opacity(message.mode == .queue ? 0.55 : 1) + if message.mode == .queue { + Text(AidenBotSessionCopy.queued) + .font(.caption) + .foregroundStyle(palette.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.leading, 48) + .accessibilityElement(children: .combine) + .accessibilityHint(message.mode == .queue ? "Sends when the current reply finishes" : "") + } + + private var draftIsEmpty: Bool { + draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Steer or Queue while the Bot works (desktop and Android parity): the mode's + /// name sends in that mode; touch and hold for the menu. + private func busyPill(_ model: AidenBotSessionModel) -> some View { + let enabled = canWrite && model.canSend && !draftIsEmpty + return Menu { + ForEach(AidenStreamInputMode.busyModes, id: \.self) { mode in + Button { + busyMode = mode + } label: { + if busyMode == mode { + Label { Text(mode.busyLabel); Text(mode.busyDetail) } icon: { Image(systemName: "checkmark") } + } else { + Text(mode.busyLabel) + Text(mode.busyDetail) + } + } + .accessibilityAddTraits(busyMode == mode ? .isSelected : []) + } + } label: { + Text(busyMode.busyLabel) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .frame(minHeight: 40) + .foregroundStyle(enabled ? palette.onAccent : palette.secondary) + .background(enabled ? palette.accent : palette.raised, in: Capsule()) + } primaryAction: { + let text = draft + let mode = busyMode + Task { if await model.send(text, whenBusy: mode) { draft = "" } } + } + .menuStyle(.button) + .buttonStyle(.plain) + .disabled(!enabled) + .accessibilityLabel(busyMode.busyActionLabel) + .accessibilityHint("Touch and hold to choose Steer or Queue") + } +``` + +7. In `composer(_:)`, inside `if model.isRunning {`, put `if model.offersBusyModes { busyPill(model) }` before the Stop `Button`. +8. At the end of the file, add: + +```swift +/// The status line while a Bot works (spec §12): a native activity mark and the +/// shared word, plus the elapsed time after 20 seconds. No steps, no Stop. +struct AidenBotStatusLineView: View { + let model: AidenBotSessionModel + + @Environment(\.aidenPalette) private var palette + @State private var announcer = AidenBotStatusAnnouncer() + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + if let line = model.statusLine(nowMs: Int(context.date.timeIntervalSince1970 * 1_000)) { + HStack(spacing: 8) { + AidenActivityMark(mark: line.display.mark, size: 18, color: palette.secondary) + Text(line.display.word) + if let elapsed = line.elapsed { + Text("· \(elapsed)").monospacedDigit() + } + } + .font(.subheadline) + .foregroundStyle(palette.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel(line.accessibilityLabel) + .accessibilityAddTraits(.updatesFrequently) + .onChange(of: line.display.word, initial: true) { _, word in announce(word) } + } + } + } + + /// VoiceOver hears a change at most once every 2 seconds; the newest word wins. + private func announce(_ word: String) { + let offer = announcer.offer(word, at: Date().timeIntervalSinceReferenceDate) + if let text = offer.announce { + AccessibilityNotification.Announcement(text).post() + } else if let retry = offer.retryAfter { + Task { @MainActor in + try? await Task.sleep(for: .seconds(retry)) + if let latest = model.statusLine(nowMs: Int(Date().timeIntervalSince1970 * 1_000))?.display.word { + announce(latest) + } + } + } + } +} +``` + +- [ ] **Step 5: Run the tests and look at it** + +Run: `xcodebuild test … -only-testing:AidenOnTheGoTests/AidenBotFaceGlanceTests -only-testing:AidenOnTheGoTests/AidenBotSessionActivityTests -only-testing:AidenOnTheGoTests/AidenBotHomeProfileTests` +Expected: PASS. + +Then build and run the app on the iPhone 17 Pro simulator against a Mac running this branch. Check four things: +1. Send to a Bot: the line shows "Getting ready" → "Thinking" → … and disappears as the reply streams. +2. While it works, the pill reads Queue, and touch-and-hold offers Steer. A queued message shows faded with "Queued". +3. In Bots home, the working Bot's face glances. +4. With Settings → Accessibility → Reduce Motion on, the face and mark hold still. + +Capture screenshots, light and dark. + +- [ ] **Step 6: Commit** + +```bash +git add ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift ios/AidenOnTheGoTests/AidenBotSessionTests.swift +git commit -m "iOS Bots: status line, Steer/Queue pill, Queued bubbles and glancing faces" +``` + +--- + +### Task 7: iOS Advanced → Chat activity + +**Files:** +- Modify: `ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift` +- Test: `ios/AidenOnTheGoTests/AidenBotSessionTests.swift` (new class `AidenBotChatActivityTests`) + +**Interfaces:** +- Consumes: + - `AidenBotChatActivity*` and `AidenBotIdentityPatch(chatActivity:)` (Task 4). + - `AidenBotActivityPhase.display` (Task 4). + - `AidenRemoteClient.updateBotIdentity(id:revision:patch:)`. + - `AidenServer.supportsBotActivity` (Task 4). +- Produces: + - `enum AidenBotChatActivityCopy` (labels, descriptions, `summary(_:)`). + - `enum AidenBotChatActivityPreview { struct Frame; static let durations; static let stillFrame; static func frameIndex(elapsed:) -> Int; static func frame(index:level:) -> Frame }`. + - `@Observable final class AidenBotChatActivityModel { value; isSaving; errorMessage; func pick(_:) async }`. + - `struct AidenBotChatActivityView`. + +- [ ] **Step 1: Write the failing tests** + +Append to `AidenBotSessionTests.swift`: + +```swift +@MainActor +final class AidenBotChatActivityTests: XCTestCase { + private struct SaveFailure: Error {} + + private func detail(revision: String, _ activity: AidenBotChatActivity?) throws -> AidenBotDetail { + let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "contract", withExtension: "json")) + let shared = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + var object = try XCTUnwrap(shared["botDetail"] as? [String: Any]) + object["revision"] = revision + if let activity { object["chatActivity"] = ["level": activity.level.rawValue, "wording": activity.wording.rawValue] } + return try AidenRemoteJSONDecoder.decode(AidenBotDetail.self, from: JSONSerialization.data(withJSONObject: object)) + } + + // Test 1 + func testAPickSavesAtOnceWithTheBotsRevisionAndTheNextPickUsesTheNewOne() async throws { + var calls: [(AidenBotChatActivity, String)] = [] + var saved: [AidenBotDetail] = [] + let start = try detail(revision: "rev_1", nil) + let model = AidenBotChatActivityModel(bot: start, save: { next, revision in + calls.append((next, revision)) + return try self.detail(revision: revision == "rev_1" ? "rev_2" : "rev_3", next) + }, onSaved: { saved.append($0) }) + XCTAssertEqual(model.value, .standard, "a Bot that never chose reads as Quiet, Follows the work") + + await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) + await model.pick(AidenBotChatActivity(level: .summary, wording: .thinking)) + XCTAssertEqual(calls.map(\.1), ["rev_1", "rev_2"]) + XCTAssertEqual(model.value, AidenBotChatActivity(level: .summary, wording: .thinking)) + XCTAssertEqual(saved.map(\.revision), ["rev_2", "rev_3"]) + + await model.pick(AidenBotChatActivity(level: .summary, wording: .thinking)) + XCTAssertEqual(calls.count, 2, "picking the current choice sends nothing") + } + + // Test 2 (Review Focus 5) + func testAFailedSaveReturnsToTheSavedChoiceAndSaysSo() async throws { + var revisions: [String] = [] + var fail = true + let model = AidenBotChatActivityModel(bot: try detail(revision: "rev_1", AidenBotChatActivity(level: .quiet, wording: .follows)), save: { next, revision in + revisions.append(revision) + if fail { throw SaveFailure() } + return try self.detail(revision: "rev_2", next) + }, onSaved: { _ in }) + await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) + XCTAssertEqual(model.value, AidenBotChatActivity(level: .quiet, wording: .follows)) + XCTAssertEqual(model.errorMessage, "Aiden couldn’t save that choice.") + XCTAssertFalse(model.isSaving) + + fail = false + await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) + XCTAssertEqual(revisions, ["rev_1", "rev_1"], "a failed save leaves the revision where it was") + XCTAssertEqual(model.value.level, .detailed) + } + + // Test 3 + func testPreviewsShowWhatEachLevelLooksLikeOnTheMac() { + let reading = 1 + let reply = AidenBotChatActivityPreview.durations.count - 1 + XCTAssertEqual(AidenBotChatActivityPreview.frame(index: reading, level: .quiet).phase, "reading") + XCTAssertEqual(AidenBotChatActivityPreview.frame(index: reading, level: .quiet).steps, []) + XCTAssertEqual(AidenBotChatActivityPreview.frame(index: 2, level: .detailed).steps, ["Read 1 file", "Searched the web"]) + XCTAssertNil(AidenBotChatActivityPreview.frame(index: reply, level: .quiet).recap) + let summary = AidenBotChatActivityPreview.frame(index: reply, level: .summary) + XCTAssertEqual(summary.recap, "Worked for 18s · read 1 file, searched the web, edited 1 file") + XCTAssertFalse(summary.recapOpen) + let detailed = AidenBotChatActivityPreview.frame(index: reply, level: .detailed) + XCTAssertTrue(detailed.recapOpen) + XCTAssertEqual(detailed.steps.count, 3) + XCTAssertNil(detailed.phase, "the reply has landed") + + XCTAssertEqual(AidenBotChatActivityPreview.frameIndex(elapsed: 0), 0) + XCTAssertEqual(AidenBotChatActivityPreview.frameIndex(elapsed: 1.5), 1) + XCTAssertEqual(AidenBotChatActivityPreview.frameIndex(elapsed: 6.5), reply) + XCTAssertEqual(AidenBotChatActivityPreview.frameIndex(elapsed: 8.8), 0, "the turn loops") + XCTAssertNotNil(AidenBotChatActivityPreview.frame(index: AidenBotChatActivityPreview.stillFrame, level: .quiet).phase, + "Reduce Motion's still frame shows the line mid-turn") + } + + // Test 4 + func testTheAdvancedRowSummarizesTheChoice() { + XCTAssertEqual(AidenBotChatActivityCopy.summary(nil), "Quiet · Follows the work") + XCTAssertEqual(AidenBotChatActivityCopy.summary(AidenBotChatActivity(level: .detailed, wording: .thinking)), "Detailed · Always “Thinking”") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `xcodebuild test … -only-testing:AidenOnTheGoTests/AidenBotChatActivityTests` +Expected: build FAILS. `AidenBotChatActivityModel` is undefined. + +- [ ] **Step 3: The model, copy and previews** + +Append to `ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift`: + +```swift +// MARK: - Chat activity (spec §7, contract revision 30) + +/// Copy shared with the desktop page (`bot-chat-activity-page.tsx`) and Android. +enum AidenBotChatActivityCopy { + static let title = "Chat activity" + static let levelTitle = "How much to show" + static let wordingTitle = "What the status line says" + static let phonesTitle = "On iPhone and Android" + static let phonesFooter = "Always just the line, with the same words." + static let recommended = "Recommended" + static let saveFailed = "Aiden couldn’t save that choice." + + static func label(_ level: AidenBotChatActivityLevel) -> String { + switch level { + case .quiet: "Quiet" + case .summary: "Summary" + case .detailed: "Detailed" + } + } + + static func detail(_ level: AidenBotChatActivityLevel) -> String { + switch level { + case .quiet: "One line that says what it’s doing." + case .summary: "Adds a short recap above each reply." + case .detailed: "Also shows its last few steps while it works." + } + } + + static func label(_ wording: AidenBotChatActivityWording) -> String { + switch wording { + case .follows: "Follows the work" + case .thinking: "Always “Thinking”" + } + } + + static func detail(_ wording: AidenBotChatActivityWording) -> String { + switch wording { + case .follows: "Says what it’s doing, like Reading or Searching the web." + case .thinking: "One calm word, whatever it’s doing." + } + } + + /// Advanced's trailing value: "Quiet · Follows the work". + static func summary(_ value: AidenBotChatActivity?) -> String { + let resolved = value ?? .standard + return "\(label(resolved.level)) · \(label(resolved.wording))" + } + + static func description(botName: String) -> String { + "What you see in \(botName)’s chat on your Mac while it works. Changes save as you pick them." + } +} + +/// One scripted turn, as each level shows it on the Mac: think, read, search, edit, reply. +enum AidenBotChatActivityPreview { + struct Frame: Equatable { + /// The line's phase, or nil on the reply frame. + let phase: String? + /// Step rows: Detailed while live, or the open recap after the reply. + let steps: [String] + let recap: String? + let recapOpen: Bool + } + + static let durations: [TimeInterval] = [1.4, 1.6, 1.6, 1.4, 2.8] + /// Reduce Motion's still frame: mid-turn, with the line showing. + static let stillFrame = 2 + static let prompt = "Plan dinners for this week" + static let reply = "Here’s a plan for the week." + static let recap = "Worked for 18s · read 1 file, searched the web, edited 1 file" + + private static let phases: [String?] = ["thinking", "reading", "searching", "editing", nil] + private static let stepLabels = ["Read 1 file", "Searched the web", "Edited 1 file"] + + static func frameIndex(elapsed: TimeInterval) -> Int { + let total = durations.reduce(0, +) + var time = elapsed.truncatingRemainder(dividingBy: total) + if time < 0 { time += total } + for (index, duration) in durations.enumerated() { + if time < duration { return index } + time -= duration + } + return durations.count - 1 + } + + static func frame(index: Int, level: AidenBotChatActivityLevel) -> Frame { + let clamped = min(max(index, 0), phases.count - 1) + if let phase = phases[clamped] { + return Frame(phase: phase, steps: level == .detailed ? Array(stepLabels.prefix(clamped)) : [], recap: nil, recapOpen: false) + } + switch level { + case .quiet: return Frame(phase: nil, steps: [], recap: nil, recapOpen: false) + case .summary: return Frame(phase: nil, steps: [], recap: recap, recapOpen: false) + case .detailed: return Frame(phase: nil, steps: stepLabels, recap: recap, recapOpen: true) + } + } +} + +/// Saves each pick at once (no Save button), like the Mac page. +@MainActor +@Observable +final class AidenBotChatActivityModel { + private(set) var value: AidenBotChatActivity + private(set) var isSaving = false + var errorMessage: String? + + @ObservationIgnored private var revision: String + @ObservationIgnored private let save: @MainActor (AidenBotChatActivity, String) async throws -> AidenBotDetail + @ObservationIgnored private let onSaved: @MainActor (AidenBotDetail) -> Void + + init( + bot: AidenBotDetail, + save: @escaping @MainActor (AidenBotChatActivity, String) async throws -> AidenBotDetail, + onSaved: @escaping @MainActor (AidenBotDetail) -> Void + ) { + value = bot.chatActivity ?? .standard + revision = bot.revision + self.save = save + self.onSaved = onSaved + } + + /// Shows the pick at once; a failed save returns to the saved choice. + func pick(_ next: AidenBotChatActivity) async { + guard next != value, !isSaving else { return } + let previous = value + value = next + isSaving = true + defer { isSaving = false } + do { + let saved = try await save(next, revision) + revision = saved.revision + value = saved.chatActivity ?? next + onSaved(saved) + } catch is CancellationError { + value = previous + } catch { + value = previous + errorMessage = AidenBotChatActivityCopy.saveFailed + } + } +} +``` + +- [ ] **Step 4: The page and the Advanced row** + +Append to the same file: + +```swift +struct AidenBotChatActivityView: View { + let botName: String + let canWrite: Bool + @State private var model: AidenBotChatActivityModel + @Environment(\.aidenPalette) private var palette + + init(bot: AidenBotDetail, canWrite: Bool, model: AidenBotChatActivityModel) { + botName = bot.name + self.canWrite = canWrite + _model = State(initialValue: model) + } + + var body: some View { + Form { + Section { + Text(AidenBotChatActivityCopy.description(botName: botName)) + .font(.footnote) + .foregroundStyle(palette.secondary) + } + Section(AidenBotChatActivityCopy.levelTitle) { + ForEach(AidenBotChatActivityLevel.allCases, id: \.self) { level in + choiceRow( + title: AidenBotChatActivityCopy.label(level), + detail: AidenBotChatActivityCopy.detail(level), + recommended: level == .quiet, + selected: model.value.level == level, + preview: AidenBotChatActivityPreviewView(level: level, wording: model.value.wording) + ) { Task { await model.pick(AidenBotChatActivity(level: level, wording: model.value.wording)) } } + } + } + Section(AidenBotChatActivityCopy.wordingTitle) { + ForEach(AidenBotChatActivityWording.allCases, id: \.self) { wording in + choiceRow( + title: AidenBotChatActivityCopy.label(wording), + detail: AidenBotChatActivityCopy.detail(wording), + recommended: false, + selected: model.value.wording == wording, + preview: AidenBotChatActivityPreviewView(level: .quiet, wording: wording) + ) { Task { await model.pick(AidenBotChatActivity(level: model.value.level, wording: wording)) } } + } + } + Section { + AidenBotChatActivityPreviewView(level: .quiet, wording: model.value.wording) + } header: { + Text(AidenBotChatActivityCopy.phonesTitle) + } footer: { + Text(AidenBotChatActivityCopy.phonesFooter) + } + } + .scrollContentBackground(.hidden) + .background(palette.canvas) + .navigationTitle(AidenBotChatActivityCopy.title) + .navigationBarTitleDisplayMode(.inline) + .disabled(!canWrite || model.isSaving) + .alert( + "Couldn’t Save", + isPresented: Binding(get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } }) + ) { + Button("OK", role: .cancel) { model.errorMessage = nil } + } message: { + Text(model.errorMessage ?? AidenBotChatActivityCopy.saveFailed) + } + } + + /// A radio row: the radio and the row's background show the choice; there is no border. + private func choiceRow( + title: String, + detail: String, + recommended: Bool, + selected: Bool, + preview: AidenBotChatActivityPreviewView, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: selected ? "largecircle.fill.circle" : "circle") + .foregroundStyle(selected ? palette.accent : palette.secondary) + .font(.title3) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(title).foregroundStyle(palette.foreground) + if recommended { + Text(AidenBotChatActivityCopy.recommended) + .font(.caption.weight(.medium)) + .foregroundStyle(palette.secondary) + } + } + Text(detail).font(.footnote).foregroundStyle(palette.secondary) + preview + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground(selected ? palette.raised : nil) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton) + .accessibilityHint(detail) + } +} + +/// A small chat replaying one scripted turn. It holds its still frame under Reduce Motion. +struct AidenBotChatActivityPreviewView: View { + let level: AidenBotChatActivityLevel + let wording: AidenBotChatActivityWording + + @Environment(\.aidenPalette) private var palette + @Environment(\.accessibilityReduceMotion) private var systemReduceMotion + @Environment(\.aidenReduceMotion) private var appReduceMotion + @State private var started = Date() + + private var still: Bool { systemReduceMotion || appReduceMotion } + + var body: some View { + TimelineView(.animation(minimumInterval: 0.2, paused: still)) { context in + let index = still + ? AidenBotChatActivityPreview.stillFrame + : AidenBotChatActivityPreview.frameIndex(elapsed: context.date.timeIntervalSince(started)) + content(AidenBotChatActivityPreview.frame(index: index, level: level)) + } + .padding(10) + .background(palette.canvas, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Preview") + } + + @ViewBuilder + private func content(_ frame: AidenBotChatActivityPreview.Frame) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(AidenBotChatActivityPreview.prompt) + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(palette.raised, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .frame(maxWidth: .infinity, alignment: .trailing) + if let recap = frame.recap { + Label(recap, systemImage: frame.recapOpen ? "chevron.down" : "chevron.right") + .font(.caption2) + .foregroundStyle(palette.secondary) + } + ForEach(frame.steps, id: \.self) { step in + Text(step).font(.caption2).foregroundStyle(palette.secondary).padding(.leading, 14) + } + if let phase = frame.phase { + let shown = AidenBotActivityPhase.display(phase: phase, wording: wording, app: nil) + HStack(spacing: 6) { + AidenActivityMark(mark: shown.mark, size: 14, active: !still, color: palette.secondary) + Text(shown.word).font(.caption) + } + .foregroundStyle(palette.secondary) + } else { + Text(AidenBotChatActivityPreview.reply).font(.caption).foregroundStyle(palette.foreground) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} +``` + +In `AidenBotAdvancedView`: +1. In `form(_:)`, after `accessSection(catalog)`, add `chatActivitySection`. +2. Add: + +```swift + /// Revision 30: hidden for Macs that don't announce `bot-activity-v1` (§11, Older Macs). + @ViewBuilder + private var chatActivitySection: some View { + if coordinator.server?.supportsBotActivity == true, let baselineBot { + Section { + NavigationLink { + AidenBotChatActivityView( + bot: baselineBot, + canWrite: canWrite, + model: AidenBotChatActivityModel(bot: baselineBot, save: { next, revision in + let context = try coordinator.requestContext() + let client = try coordinator.remoteClient(for: context) + return try await client.updateBotIdentity( + id: baselineBot.id, + revision: revision, + patch: try AidenBotIdentityPatch(chatActivity: next) + ) + }, onSaved: { saved in + // Keep Advanced's own Save on the newest revision. + self.baselineBot = saved + }) + ) + } label: { + LabeledContent(AidenBotChatActivityCopy.title, value: AidenBotChatActivityCopy.summary(baselineBot.chatActivity)) + } + .disabled(isDirty) + } footer: { + Text(isDirty ? "Save or cancel your changes first." : "What you see in its chat on your Mac while it works.") + } + } + } +``` + +`.disabled(isDirty)` keeps the identity revision single-writer: Advanced's Save and a Chat activity pick never race on one revision. + +- [ ] **Step 5: Run the tests and look at it** + +Run: `xcodebuild test … -only-testing:AidenOnTheGoTests/AidenBotChatActivityTests -only-testing:AidenOnTheGoTests/AidenBotSessionTests` +Expected: PASS (4 new tests). + +Run the app against a revision-30 Mac. Check: +1. Advanced shows "Chat activity · Quiet · Follows the work". +2. Picking Summary saves at once, and the Mac's chat shows the recap on its next reply. +3. Previews loop, and hold still under Reduce Motion. +4. Against a revision-29 Mac, the row is absent. + +- [ ] **Step 6: Commit** + +```bash +git add ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift ios/AidenOnTheGoTests/AidenBotSessionTests.swift +git commit -m "iOS Bots: Advanced → Chat activity with looping previews, saved on pick" +``` + +--- + +### Task 8: Android wire models, the `bot:activity` opt-in, the phase table and elapsed time + +**Files:** +- Modify: `android/.../models/AidenBotSession.kt`, `android/.../models/AidenBot.kt`, `android/.../protocol/AidenRemoteProtocol.kt`, `android/.../models/AidenInstallation.kt`, `android/.../features/remote/AidenRemoteCoordinator.kt`, `android/.../networking/AidenRemoteClient.kt`, `android/app/src/main/res/values/strings.xml` +- Create: `android/.../features/bots/AidenBotActivity.kt` +- Test: create `android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt` + +**Interfaces:** +- Consumes: Task 1's fixture sections. +- Produces: + - `data class AidenBotSessionActivity(phase, app?, startedAt: Long)` and `data class AidenBotQueuedMessage(submissionId, mode: AidenStreamInputMode, text)`. + - `AidenBotSession.activity: AidenBotSessionActivity?` and `AidenBotSession.queued: List?`. + - `AidenBotSessionEventPayload.Activity(activity?)` and `AidenBotSessionEventPayload.Queued(queued)`. + - `AidenBotSessionSendRequest(text, whenBusy?)`. + - `enum AidenBotChatActivityLevel`, `enum AidenBotChatActivityWording` and `data class AidenBotChatActivity(level, wording) { companion DEFAULT }`. + - `AidenBotDetail.chatActivity` and `AidenBotIdentityPatch.chatActivity`. + - `AidenRemoteProtocol.BOT_ACTIVITY_FEATURE`, `AidenRemoteCapability.BOT_ACTIVITY` and `PHONE_BOT_ACTIVITY`. + - `AidenServer.offersBotActivity` and `AidenServer.supportsBotActivity`. + - `AidenRemoteClient.sendBotMessage(botId, text, idempotencyKey, whenBusy = null)`. + - In `AidenBotActivity.kt`: + - `enum class AidenBotActivityPhase(id, @StringRes word, mark) { companion fun of(id) }` + - `data class AidenBotActivityDisplay(phase, app?)` and `aidenBotActivityDisplay(phase, wording, app)` + - `aidenBotElapsedLabel(startedAt, nowMs): String?` and `aidenBotDurationLabel(ms)` + - `data class AidenBotAnnouncement(text, atMs)`, `data class AidenBotAnnouncementStep(shown, retryInMs)` and `aidenBotNextAnnouncement(current, text, nowMs, gapMs = 2_000)` + - `data class AidenBotFaceGlance(dxFraction, blink)` and `aidenBotFaceGlance(timeMs)` + +- [ ] **Step 1: Write the failing tests** + +Create `android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt`: + +```kotlin +package sbtbiswas.AidenOnTheGo + +import androidx.annotation.StringRes +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test +import sbtbiswas.AidenOnTheGo.features.bots.AidenBotActivityPhase +import sbtbiswas.AidenOnTheGo.features.bots.aidenBotActivityDisplay +import sbtbiswas.AidenOnTheGo.features.bots.aidenBotElapsedLabel +import sbtbiswas.AidenOnTheGo.features.bots.aidenBotFaceGlance +import sbtbiswas.AidenOnTheGo.features.bots.aidenBotNextAnnouncement +import sbtbiswas.AidenOnTheGo.models.* +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +/** Contract revision 30: the activity wire, read from the fixture every client shares. */ +class AidenBotActivityContractTest { + private val json = AidenBotWireJson.json + private val fixture: JsonObject by lazy { + val text = requireNotNull(javaClass.classLoader?.getResourceAsStream("contract.json")) { "contract.json" } + .bufferedReader().use { it.readText() } + json.parseToJsonElement(text).jsonObject + } + private val activity get() = fixture.getValue("botActivity").jsonObject + private val privacy get() = fixture.getValue("botActivityPrivacy").jsonObject + private val started = 1_760_450_000_000L + + /** The English copy in `strings.xml`, by resource name. */ + private val strings: Map by lazy { + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(File("src/main/res/values/strings.xml")) + val nodes = document.getElementsByTagName("string") + (0 until nodes.length).associate { index -> + val element = nodes.item(index) as org.w3c.dom.Element + element.getAttribute("name") to element.textContent + } + } + + private fun english(@StringRes id: Int): String = + strings.getValue(R.string::class.java.fields.first { it.getInt(null) == id }.name) + + // Test 1 (Review Focus 3) + @Test + fun activitySessionFramesAndDetailDecodeAsTheMacSendsThem() { + val session = json.decodeFromJsonElement(AidenBotSession.serializer(), activity.getValue("session")) + assertEquals(AidenBotSessionActivity("app", "Notion", started), session.activity) + assertEquals(listOf("18", "19"), session.queued?.map { it.submissionId }) + assertEquals(listOf(AidenStreamInputMode.QUEUE, AidenStreamInputMode.STEER), session.queued?.map { it.mode }) + + val events = json.decodeFromJsonElement(ListSerializer(AidenBotSessionEvent.serializer()), activity.getValue("events")) + assertEquals( + listOf( + AidenBotSessionEventPayload.Activity(AidenBotSessionActivity("searching", null, started)), + AidenBotSessionEventPayload.Queued(listOf(AidenBotQueuedMessage("18", AidenStreamInputMode.QUEUE, "Also add a shopping list."))), + AidenBotSessionEventPayload.Activity(AidenBotSessionActivity("teleporting", null, started)), + AidenBotSessionEventPayload.Activity(null), + ), + events.map { it.payload } + ) + // Frames re-encode as the Mac sent them. + assertEquals(activity.getValue("events"), json.encodeToJsonElement(ListSerializer(AidenBotSessionEvent.serializer()), events)) + // A newer Mac's phase decodes and reads as Working. + assertEquals( + aidenBotActivityDisplay("working", AidenBotChatActivityWording.FOLLOWS, null), + aidenBotActivityDisplay("teleporting", AidenBotChatActivityWording.FOLLOWS, null) + ) + + val detail = json.decodeFromJsonElement(AidenBotDetail.serializer(), activity.getValue("detail")) + assertEquals(AidenBotChatActivity(AidenBotChatActivityLevel.SUMMARY, AidenBotChatActivityWording.THINKING), detail.chatActivity) + assertNull(json.decodeFromJsonElement(AidenBotDetail.serializer(), fixture.getValue("botDetail")).chatActivity) + } + + // Test 2 + @Test + fun everyPrivacySampleIsRefused() { + for (sample in privacy.getValue("rejectedActivities").jsonArray) { + assertThrows(sample.toString(), Exception::class.java) { + json.decodeFromJsonElement(AidenBotSessionActivity.serializer(), sample) + } + } + for (sample in privacy.getValue("rejectedQueued").jsonArray) { + assertThrows(sample.toString(), Exception::class.java) { + json.decodeFromJsonElement(AidenBotQueuedMessage.serializer(), sample) + } + } + for (sample in privacy.getValue("rejectedChatActivity").jsonArray) { + assertThrows(sample.toString(), Exception::class.java) { + json.decodeFromJsonElement(AidenBotChatActivity.serializer(), sample) + } + } + val session = activity.getValue("session").jsonObject + val twentyOne = JsonArray((0..20).map { + JsonObject(mapOf("submissionId" to JsonPrimitive("$it"), "mode" to JsonPrimitive("queue"), "text" to JsonPrimitive("x"))) + }) + assertThrows(Exception::class.java) { + json.decodeFromJsonElement(AidenBotSession.serializer(), JsonObject(session + ("queued" to twentyOne))) + } + } + + // Test 3 + @Test + fun requestsEncodeExactlyTheFixtureBodies() { + val send = activity.getValue("send").jsonObject.getValue("request") + assertEquals(send, json.encodeToJsonElement(AidenBotSessionSendRequest.serializer(), AidenBotSessionSendRequest("Keep it vegetarian.", AidenStreamInputMode.STEER))) + assertEquals( + JsonObject(mapOf("text" to JsonPrimitive("Hi"))), + json.encodeToJsonElement(AidenBotSessionSendRequest.serializer(), AidenBotSessionSendRequest("Hi")) + ) + val patch = activity.getValue("identityPatch").jsonObject.getValue("request") + assertEquals( + patch, + json.encodeToJsonElement( + AidenBotIdentityPatch.serializer(), + AidenBotIdentityPatch(chatActivity = AidenBotChatActivity(AidenBotChatActivityLevel.DETAILED, AidenBotChatActivityWording.FOLLOWS)) + ) + ) + } + + // Test 4 + @Test + fun phaseTableIsTheOneTheMacDraws() { + val phases = activity.getValue("phases").jsonArray.map { it.jsonObject } + val keeps = activity.getValue("thinkingKeeps").jsonArray.map { it.jsonPrimitive.content }.toSet() + assertEquals(phases.map { it.getValue("id").jsonPrimitive.content }, AidenBotActivityPhase.entries.map { it.id }) + for (row in phases) { + val id = row.getValue("id").jsonPrimitive.content + val shown = aidenBotActivityDisplay(id, AidenBotChatActivityWording.FOLLOWS, null) + assertEquals(id, shown.phase.id) + assertEquals(id, row.getValue("word").jsonPrimitive.content, english(shown.phase.word)) + assertEquals(id, row.getValue("mark").jsonPrimitive.content, shown.mark.wireName) + val thinking = aidenBotActivityDisplay(id, AidenBotChatActivityWording.THINKING, null) + assertEquals(id, if (id in keeps) id else "thinking", thinking.phase.id) + } + val app = activity.getValue("app").jsonObject + val named = aidenBotActivityDisplay("app", AidenBotChatActivityWording.FOLLOWS, app.getValue("app").jsonPrimitive.content) + assertEquals(app.getValue("word").jsonPrimitive.content, english(R.string.bot_activity_app_named).replace("%1\$s", named.app!!)) + } + + // Test 5 + @Test + fun elapsedMatchesTheMacIncludingAPhoneWhoseClockIsBehind() { + for (row in activity.getValue("elapsed").jsonArray.map { it.jsonObject }) { + val label = row.getValue("label").let { if (it is JsonNull) null else it.jsonPrimitive.content } + assertEquals( + row.toString(), + label, + aidenBotElapsedLabel(row.getValue("startedAt").jsonPrimitive.longOrNull!!, row.getValue("now").jsonPrimitive.longOrNull!!) + ) + } + } + + // Test 6 + @Test + fun screenReadersHearAStatusChangeAtMostEveryTwoSeconds() { + val first = aidenBotNextAnnouncement(null, "Reading", 0) + assertEquals("Reading", first.shown?.text) + val same = aidenBotNextAnnouncement(first.shown, "Reading", 500) + assertEquals(first.shown, same.shown) + val early = aidenBotNextAnnouncement(first.shown, "Searching the web", 1_000) + assertEquals("Reading", early.shown?.text) + assertEquals(1_000L, early.retryInMs) + assertEquals("Searching the web", aidenBotNextAnnouncement(early.shown, "Searching the web", 2_000).shown?.text) + } + + // Test 7 + @Test + fun aWorkingFaceFollowsTheGlanceKeyframes() { + assertEquals(0f, aidenBotFaceGlance(0).dxFraction, 0.0005f) + assertEquals(1f, aidenBotFaceGlance(0).blink, 0.0005f) + assertEquals(-2.6f / 24f, aidenBotFaceGlance(1_280).dxFraction, 0.0005f) + assertEquals(2.6f / 24f, aidenBotFaceGlance(2_400).dxFraction, 0.0005f) + assertEquals(0.1f, aidenBotFaceGlance(3_600).blink, 0.0005f) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd android && JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:testDebugUnitTest --console=plain --tests 'sbtbiswas.AidenOnTheGo.AidenBotActivityContractTest'` +Expected: compilation FAILS. `AidenBotSessionActivity`, `AidenBotActivityPhase` and the other new names are undefined. + +- [ ] **Step 3: Session wire types** + +In `android/.../models/AidenBotSession.kt`: + +1. Above `/** `GET /bots/{botId}/session`: …`, add: + +```kotlin +/** + * Contract revision 30 (`bot-activity-v1`, `bot:activity`): what a Bot in a running turn is + * doing. Exactly `phase`, `app` and `startedAt` cross the wire; the strict wire JSON refuses + * any other key (§11). An unknown phase id still decodes and reads as Working. + */ +@Serializable +data class AidenBotSessionActivity( + val phase: String, + val app: String? = null, + /** When the turn started: the Mac's clock, milliseconds since 1970. */ + @Serializable(with = AidenStrictLongSerializer::class) val startedAt: Long +) { + init { + if (!PHASE_ID.matches(phase)) throw AidenBotContractException.InvalidField("activity.phase") + app?.let { AidenBotWire.validateString(it, "activity.app", MAX_APP_LENGTH) } + if (startedAt < 0 || startedAt > AidenRemoteProtocol.MAX_SAFE_INTEGER) { + throw AidenBotContractException.InvalidField("activity.startedAt") + } + } + + companion object { + const val MAX_APP_LENGTH = 40 + private val PHASE_ID = Regex("^[a-z][a-z_]{0,31}$") + } +} + +/** A message waiting for the running turn: STEER joins it, QUEUE runs after it. */ +@Serializable +data class AidenBotQueuedMessage( + val submissionId: String, + val mode: AidenStreamInputMode, + val text: String +) { + init { + if (!SUBMISSION_ID.matches(submissionId)) throw AidenBotContractException.InvalidField("queued.submissionId") + AidenBotWire.validateString(text, "queued.text", AidenBotSessionWire.MAX_MESSAGE_LENGTH) + } + + companion object { + const val MAX_COUNT = 20 + private val SUBMISSION_ID = Regex("^[A-Za-z0-9._:-]{1,64}$") + } +} +``` + +2. `AidenBotSession`: + - Add `val activity: AidenBotSessionActivity? = null` and `val queued: List? = null` as the last constructor parameters. + - Add to `init`: + ```kotlin + queued?.let { list -> + if (list.size > AidenBotQueuedMessage.MAX_COUNT || list.map { it.submissionId }.toSet().size != list.size) { + throw AidenBotContractException.InvalidField("queued") + } + } + ``` +3. `AidenBotSessionFields`: add the same two properties with the same defaults as its last parameters. + - In `AidenBotSessionSerializer.serialize`, pass `value.activity, value.queued` to `AidenBotSessionFields(…)`. `explicitNulls = false` omits them when null, so revision-29 sessions re-encode unchanged. + - In `deserialize`, pass `activity = fields.activity, queued = fields.queued` to `AidenBotSession(…)`. +4. `AidenBotSessionSendRequest` becomes: + ```kotlin + @Serializable + data class AidenBotSessionSendRequest( + val text: String, + /** Revision 30 (`bot-activity-v1`): Steer joins the running turn, Queue runs after it. */ + val whenBusy: AidenStreamInputMode? = null + ) { + init { + AidenBotWire.validateString(text, "text", AidenBotSessionWire.MAX_MESSAGE_LENGTH) + if (text.isBlank()) throw AidenBotContractException.InvalidField("text") + } + } + ``` +5. `AidenBotSessionEventPayload`: add before `Closed`: + ```kotlin + /** Revision 30: what the running turn is doing now, or null when none runs. */ + data class Activity(val activity: AidenBotSessionActivity?) : AidenBotSessionEventPayload() + /** Revision 30: the person's messages still waiting for the turn. */ + data class Queued(val queued: List) : AidenBotSessionEventPayload() { + init { + if (queued.size > AidenBotQueuedMessage.MAX_COUNT || queued.map { it.submissionId }.toSet().size != queued.size) { + throw AidenBotContractException.InvalidField("queued") + } + } + } + ``` +6. `AidenBotSessionEvent.type`: add `is AidenBotSessionEventPayload.Activity -> "activity"` and `is AidenBotSessionEventPayload.Queued -> "queued"`. +7. `AidenBotSessionEventSerializer.serialize`'s `when`: add + ```kotlin + is AidenBotSessionEventPayload.Activity -> buildJsonObject { + put("activity", p.activity?.let { wire.encodeToJsonElement(AidenBotSessionActivity.serializer(), it) } ?: JsonNull) + } + is AidenBotSessionEventPayload.Queued -> buildJsonObject { + put("queued", wire.encodeToJsonElement(ListSerializer(AidenBotQueuedMessage.serializer()), p.queued)) + } + ``` + `decode`'s `when`: add before `"closed" ->`: + ```kotlin + "activity" -> { + payload.requireKeys(setOf("activity")) + val element = payload.getValue("activity") + AidenBotSessionEventPayload.Activity( + if (element is JsonNull) null else wire.decodeFromJsonElement(AidenBotSessionActivity.serializer(), element) + ) + } + "queued" -> { + payload.requireKeys(setOf("queued")) + AidenBotSessionEventPayload.Queued( + wire.decodeFromJsonElement(ListSerializer(AidenBotQueuedMessage.serializer()), payload.getValue("queued")) + ) + } + ``` + Add `import kotlinx.serialization.builtins.ListSerializer` if the file lacks it. + +- [ ] **Step 4: Chat activity on Bot detail and the identity patch** + +In `android/.../models/AidenBot.kt`: + +```kotlin +/** A Bot's Chat activity choice (spec §7, contract revision 30). */ +@Serializable +enum class AidenBotChatActivityLevel { + @SerialName("quiet") QUIET, + @SerialName("summary") SUMMARY, + @SerialName("detailed") DETAILED +} + +@Serializable +enum class AidenBotChatActivityWording { + @SerialName("follows") FOLLOWS, + @SerialName("thinking") THINKING +} + +@Serializable +data class AidenBotChatActivity( + val level: AidenBotChatActivityLevel, + val wording: AidenBotChatActivityWording +) { + companion object { + /** What a Bot that never chose shows: Quiet, Follows the work. */ + val DEFAULT = AidenBotChatActivity(AidenBotChatActivityLevel.QUIET, AidenBotChatActivityWording.FOLLOWS) + } +} +``` + +- In `AidenBotDetail`, add `/** Revision 30 (`bot:activity`). */ val chatActivity: AidenBotChatActivity? = null,` after `visionModelSelection`. +- In `AidenBotIdentityPatch`: + - Add `val chatActivity: AidenBotChatActivity? = null` as the last parameter. + - Change its `init` emptiness check to `if (name == null && purpose == null && instructions == null && avatar == null && chatActivity == null)`. + +- [ ] **Step 5: The opt-in, the host flags and the client** + +1. `android/.../protocol/AidenRemoteProtocol.kt`: + - Add `/** Contract revision 30: the status line, Steer and Queue, Queued bubbles and Chat activity. */ const val BOT_ACTIVITY_FEATURE = "bot-activity-v1"` below `BOT_PROACTIVE_FEATURE`. + - In `AidenRemoteCapability`'s companion, below `BOT_CARDS`, add: + ```kotlin + /** + * Contract revision 30: this phone receives the activity and queued session frames and + * Bot detail `chatActivity`. It grants no authority. + */ + val BOT_ACTIVITY = AidenRemoteCapability("bot:activity") + ``` + - Below `PHONE_BOT_CARDS`, add `/** Negotiable behind `bot-activity-v1`. */ val PHONE_BOT_ACTIVITY = listOf(BOT_ACTIVITY)`. + - Append `+ PHONE_BOT_ACTIVITY` to both `NEGOTIABLE` and `PHONE_KNOWN`. +2. `android/.../models/AidenInstallation.kt`, in `AidenServer`, below `offersBotCards`: + ```kotlin + /** The Mac offers `bot:activity` (revision 30), so this phone asks for it. */ + val offersBotActivity: Boolean + get() = features.contains(AidenRemoteProtocol.BOT_ACTIVITY_FEATURE) + + /** Announced and held: show the status line, Steer and Queue, Queued bubbles and Chat activity. */ + val supportsBotActivity: Boolean + get() = offersBotActivity && capabilities.contains(AidenRemoteCapability.BOT_ACTIVITY) + ``` +3. `android/.../features/remote/AidenRemoteCoordinator.kt`, after the `offersBotCards` block: + ```kotlin + // Contract revision 30: activity frames and Chat activity. Older Macs never announce it. + if (server.offersBotActivity) { + for (capability in AidenRemoteCapability.PHONE_BOT_ACTIVITY) { + if (!installation.hasNegotiatedAccess(capability)) add(capability) + } + } + ``` +4. `android/.../networking/AidenRemoteClient.kt`: change `sendBotMessage` to take `whenBusy: AidenStreamInputMode? = null` as its last parameter, and build the body with `AidenBotSessionSendRequest(text, whenBusy)`. + +- [ ] **Step 6: The phase table, elapsed time, announcements and the glance** + +Add to `android/app/src/main/res/values/strings.xml`, in the Bots section: + +```xml + + Getting ready + Thinking + Reading + Looking at your photo + Searching the web + Running a command + Editing files + Using an app + Using %1$s + Asking a helper + Using your Mac + Remembering + Setting up a routine + Working + Writing + Waiting for you + Tidying up its notes + Stopping + Picking up where it left off + Reconnecting to your Mac + %1$s · %2$s + Queued + Sends when the current reply finishes +``` + +Create `android/.../features/bots/AidenBotActivity.kt`: + +```kotlin +package sbtbiswas.AidenOnTheGo.features.bots + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import sbtbiswas.AidenOnTheGo.R +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMarkKind +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMarkProperty +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.spec +import sbtbiswas.AidenOnTheGo.models.AidenBotChatActivityWording + +/** + * The status line's words and marks (spec §5), in §5's order. Kept in step with + * `renderer/shared/bot-activity.ts` and iOS by the shared fixture's `botActivity.phases`. + */ +enum class AidenBotActivityPhase(val id: String, @StringRes val word: Int, val mark: AidenActivityMarkKind) { + PREPARING("preparing", R.string.bot_activity_preparing, AidenActivityMarkKind.BOUNCE), + THINKING("thinking", R.string.bot_activity_thinking, AidenActivityMarkKind.TRI_STEP), + READING("reading", R.string.bot_activity_reading, AidenActivityMarkKind.SCAN_GRID), + PHOTO("photo", R.string.bot_activity_photo, AidenActivityMarkKind.SCAN_GRID), + SEARCHING("searching", R.string.bot_activity_searching, AidenActivityMarkKind.SCAN_GRID), + COMMAND("command", R.string.bot_activity_command, AidenActivityMarkKind.QUAD_SHUFFLE), + EDITING("editing", R.string.bot_activity_editing, AidenActivityMarkKind.QUAD_SHUFFLE), + APP("app", R.string.bot_activity_app, AidenActivityMarkKind.QUAD_SHUFFLE), + HELPER("helper", R.string.bot_activity_helper, AidenActivityMarkKind.QUAD_SHUFFLE), + COMPUTER("computer", R.string.bot_activity_computer, AidenActivityMarkKind.QUAD_SHUFFLE), + REMEMBERING("remembering", R.string.bot_activity_remembering, AidenActivityMarkKind.COMPOSE), + ROUTINE("routine", R.string.bot_activity_routine, AidenActivityMarkKind.TRI_STEP), + WORKING("working", R.string.bot_activity_working, AidenActivityMarkKind.QUAD_SHUFFLE), + WRITING("writing", R.string.bot_activity_writing, AidenActivityMarkKind.COMPOSE), + WAITING("waiting", R.string.bot_activity_waiting, AidenActivityMarkKind.GLANCE), + TIDYING("tidying", R.string.bot_activity_tidying, AidenActivityMarkKind.BOUNCE), + STOPPING("stopping", R.string.bot_activity_stopping, AidenActivityMarkKind.BOUNCE), + RESUMING("resuming", R.string.bot_activity_resuming, AidenActivityMarkKind.BOUNCE), + RECONNECTING("reconnecting", R.string.bot_activity_reconnecting, AidenActivityMarkKind.BOUNCE); + + companion object { + private val byId = entries.associateBy { it.id } + + /** An id this build doesn't know reads as Working (§5.2). */ + fun of(id: String): AidenBotActivityPhase = byId[id] ?: WORKING + } +} + +/** These change what the person should do, so Always "Thinking" keeps them. */ +private val KEEPS_ITS_WORD = setOf(AidenBotActivityPhase.WAITING, AidenBotActivityPhase.STOPPING, AidenBotActivityPhase.RECONNECTING) + +/** What the line shows: a phase plus, for "Using {app}", the connection's label. */ +data class AidenBotActivityDisplay(val phase: AidenBotActivityPhase, val app: String?) { + val mark: AidenActivityMarkKind get() = phase.mark +} + +fun aidenBotActivityDisplay(phase: String, wording: AidenBotChatActivityWording, app: String?): AidenBotActivityDisplay { + val known = AidenBotActivityPhase.of(phase) + val shown = if (wording == AidenBotChatActivityWording.THINKING && known !in KEEPS_ITS_WORD) AidenBotActivityPhase.THINKING else known + return AidenBotActivityDisplay(shown, if (shown == AidenBotActivityPhase.APP) app?.takeIf { it.isNotBlank() } else null) +} + +@Composable +fun AidenBotActivityDisplay.word(): String = + if (app != null) stringResource(R.string.bot_activity_app_named, app) else stringResource(phase.word) + +const val AIDEN_BOT_ELAPSED_AFTER_MS = 20_000L + +/** "18s", "1m 12s", "2m", "1h 2m": the Mac's wording (a JVM-tested presentation helper, like `AidenTurnElapsed`). */ +fun aidenBotDurationLabel(ms: Long): String { + val seconds = maxOf(1L, ms / 1_000) + if (seconds < 60) return "${seconds}s" + val minutes = seconds / 60 + val rest = seconds % 60 + if (minutes < 60) return if (rest > 0) "${minutes}m ${rest}s" else "${minutes}m" + val hours = minutes / 60 + val remainder = minutes % 60 + return if (remainder > 0) "${hours}h ${remainder}m" else "${hours}h" +} + +/** Nil before 20 seconds, and while this phone's clock reads behind the Mac's. */ +fun aidenBotElapsedLabel(startedAt: Long, nowMs: Long): String? { + val elapsed = nowMs - startedAt + return if (elapsed >= AIDEN_BOT_ELAPSED_AFTER_MS) aidenBotDurationLabel(elapsed) else null +} + +data class AidenBotAnnouncement(val text: String, val atMs: Long) + +/** [shown] is what to announce now; [retryInMs] says when to offer the newest text again. */ +data class AidenBotAnnouncementStep(val shown: AidenBotAnnouncement?, val retryInMs: Long?) + +/** Screen readers hear a status change at most once every [gapMs] (§14). */ +fun aidenBotNextAnnouncement(current: AidenBotAnnouncement?, text: String, nowMs: Long, gapMs: Long = 2_000): AidenBotAnnouncementStep { + if (current != null && current.text == text) return AidenBotAnnouncementStep(current, null) + if (current == null || nowMs - current.atMs >= gapMs) return AidenBotAnnouncementStep(AidenBotAnnouncement(text, nowMs), null) + return AidenBotAnnouncementStep(current, gapMs - (nowMs - current.atMs)) +} + +/** The canonical face's glance (docs/activity-marks.md `glance`): [dxFraction] of the avatar's size; [blink] scales the eyes. */ +data class AidenBotFaceGlance(val dxFraction: Float, val blink: Float) + +private val GLANCE = AidenActivityMarkKind.GLANCE.spec() + +fun aidenBotFaceGlance(timeMs: Long): AidenBotFaceGlance { + val dx = GLANCE.groups[0].track(AidenActivityMarkProperty.TRANSLATE_X)?.valueAt(timeMs) ?: 0f + val blink = GLANCE.shapes[0].track(AidenActivityMarkProperty.SCALE_Y)?.valueAt(timeMs) ?: 1f + // The mark's view box is 24 wide. + return AidenBotFaceGlance(dx / 24f, blink) +} +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `cd android && JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:testDebugUnitTest --console=plain --tests 'sbtbiswas.AidenOnTheGo.AidenBotActivityContractTest' --tests 'sbtbiswas.AidenOnTheGo.AidenBotContractTest' --tests 'sbtbiswas.AidenOnTheGo.AidenBotRevision25ContractTest'` +Expected: PASS. The revision-25 round trips are unchanged, because null `activity`/`queued` are omitted. + +- [ ] **Step 8: Commit** + +```bash +git add android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBotSession.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBot.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteProtocol.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenInstallation.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt android/app/src/main/res/values/strings.xml android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt +git commit -m "Android Bots: revision 30 activity wire, bot:activity opt-in, shared status words" +``` + +--- + +### Task 9: Android session controller: status line, Reconnecting, Queued and Steer/Queue + +**Files:** +- Modify: `android/.../features/bots/AidenBotSessionController.kt` +- Test: `android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt` + +**Interfaces:** +- Consumes: Task 8's models, `aidenBotActivityDisplay` and `aidenBotElapsedLabel`. +- Produces: + - `AidenBotSessionTransport.send(botId, text, key, whenBusy: AidenStreamInputMode? = null)`. + - `AidenBotSessionUiState.activitySupported`, `wording` and `streamDown`, plus `val offersBusyModes`. + - `data class AidenBotStatusLineState(display: AidenBotActivityDisplay, elapsed: String?)` and `fun aidenBotStatusLine(ui, nowMs): AidenBotStatusLineState?`. + - `AidenBotSessionController(…, activitySupported: Boolean = false, …)`, with `setActivitySupported(Boolean)`, `setWording(AidenBotChatActivityWording)` and `suspend fun send(text, whenBusy = null)`. + +- [ ] **Step 1: Write the failing tests** + +In `AidenBotSessionControllerTest.kt`: + +1. Change `FakeTransport`: + - Replace `override fun events(botId: String): Flow = emptyFlow()` with: + ```kotlin + /** Feeds the next `events` calls return, in order; then a feed that ends at once. */ + val feeds = ArrayDeque>() + override fun events(botId: String): Flow = feeds.removeFirstOrNull() ?: emptyFlow() + ``` + - Replace the `send` override with: + ```kotlin + val sendModes = mutableListOf() + override suspend fun send(botId: String, text: String, key: UUID, whenBusy: AidenStreamInputMode?): AidenBotSessionSendResponse { + sends += text to key + sendModes += whenBusy + sendFailures.removeFirstOrNull()?.let { throw it } + return AidenBotSessionSendResponse("1", false, AidenBotSessionState.RUNNING, false) + } + ``` +2. Add the imports `sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMarkKind` and `org.junit.Assert.assertNull`. +3. Append: + +```kotlin + // --- Revision 30: the status line, Reconnecting, Queued and Steer/Queue --- + + private val activitySession by lazy { + json.decodeFromJsonElement(AidenBotSession.serializer(), fixture.getValue("botActivity").jsonObject.getValue("session")) + } + private val started = 1_760_450_000_000L + + private fun ui( + session: AidenBotSession, + supported: Boolean = true, + wording: AidenBotChatActivityWording = AidenBotChatActivityWording.FOLLOWS + ) = AidenBotSessionUiState(session = session, isLoading = false, activitySupported = supported, wording = wording) + + // Test 1 + @Test + fun theLineShowsUntilTheReplyStreamsAndFollowsTheChosenWording() { + val line = aidenBotStatusLine(ui(activitySession), started + 1_000)!! + assertEquals(AidenBotActivityPhase.APP, line.display.phase) + assertEquals("Notion", line.display.app) + val thinking = AidenBotChatActivityWording.THINKING + assertEquals(AidenBotActivityPhase.THINKING, aidenBotStatusLine(ui(activitySession, wording = thinking), started)!!.display.phase) + val waiting = activitySession.copy(activity = AidenBotSessionActivity("waiting", null, started)) + assertEquals(AidenBotActivityPhase.WAITING, aidenBotStatusLine(ui(waiting, wording = thinking), started)!!.display.phase) + assertNull("the reply's words are the signal", aidenBotStatusLine(ui(activitySession.copy(partial = "Here is")), started)) + assertNull(aidenBotStatusLine(ui(activitySession.copy(state = AidenBotSessionState.IDLE)), started)) + } + + // Test 2 (Review Focus 3) + @Test + fun anUnknownPhaseReadsAsWorkingAndElapsedShowsAfterTwentySeconds() { + val browsing = activitySession.copy(activity = AidenBotSessionActivity("browsing", null, started)) + val early = aidenBotStatusLine(ui(browsing), started + 19_000)!! + assertEquals(AidenBotActivityPhase.WORKING, early.display.phase) + assertEquals(AidenActivityMarkKind.QUAD_SHUFFLE, early.display.mark) + assertNull(early.elapsed) + assertEquals("1m 12s", aidenBotStatusLine(ui(browsing), started + 72_000)!!.elapsed) + } + + // Test 3 + @Test + fun beforeTheFirstActivityFrameItSaysGettingReadyAndOlderMacsShowNothing() { + assertEquals( + AidenBotActivityPhase.PREPARING, + aidenBotStatusLine(ui(activitySession.copy(activity = null)), started)!!.display.phase + ) + assertNull(aidenBotStatusLine(ui(activitySession, supported = false), started)) + assertFalse(ui(activitySession, supported = false).offersBusyModes) + } + + // Test 4 (Review Focus 2) + @Test + fun aDroppedFeedReadsReconnectingUntilTheNextFrame() = runTest { + val transport = FakeTransport(activitySession) + transport.feeds += flow { throw IOException("Wi-Fi dropped") } + val reopened = activitySession.copy( + epoch = "epoch_fixture_03", + seq = 1, + activity = AidenBotSessionActivity("searching", null, started) + ) + transport.feeds += flow { + emit(AidenBotSessionEvent(reopened.botId, reopened.epoch, reopened.seq, AidenBotSessionEventPayload.Snapshot(reopened))) + awaitCancellation() + } + val controller = AidenBotSessionController( + activitySession.botId, transport, backgroundScope, activitySupported = true, reconnectDelayMillis = 1_000 + ) + controller.start() + runCurrent() + assertTrue(controller.state.value.streamDown) + assertEquals(AidenBotActivityPhase.RECONNECTING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + + advanceTimeBy(1_001) + runCurrent() + assertFalse(controller.state.value.streamDown) + assertEquals(AidenBotActivityPhase.SEARCHING, aidenBotStatusLine(controller.state.value, started)!!.display.phase) + controller.stopFollowing() + } + + // Test 5 + @Test + fun steerAndQueueGoOutOnlyWhileTheBotWorksOnAMacThatOffersThem() = runTest { + val transport = FakeTransport(activitySession) + val supported = AidenBotSessionController(activitySession.botId, transport, backgroundScope, activitySupported = true) + supported.refetch() + assertTrue(supported.state.value.offersBusyModes) + assertTrue(supported.send("Keep it vegetarian.", AidenStreamInputMode.STEER)) + val older = AidenBotSessionController(activitySession.botId, transport, backgroundScope) + older.refetch() + assertTrue(older.send("Also a list.", AidenStreamInputMode.QUEUE)) + assertEquals("an older Mac would refuse whenBusy", listOf(AidenStreamInputMode.STEER, null), transport.sendModes) + } + + // Test 6 + @Test + fun queuedAndActivityFramesFoldIntoTheSessionInSeqOrder() { + val waiting = listOf(AidenBotQueuedMessage("18", AidenStreamInputMode.QUEUE, "Also add a shopping list.")) + fun at(seq: Long, payload: AidenBotSessionEventPayload) = + AidenBotSessionEvent(activitySession.botId, activitySession.epoch, seq, payload) + val queued = aidenApplyBotSessionEvent(activitySession, at(activitySession.seq + 1, AidenBotSessionEventPayload.Queued(waiting))) + val afterQueued = (queued as AidenBotSessionEventOutcome.Applied).session + assertEquals(waiting, afterQueued.queued) + val cleared = aidenApplyBotSessionEvent(afterQueued, at(afterQueued.seq + 1, AidenBotSessionEventPayload.Activity(null))) + assertNull((cleared as AidenBotSessionEventOutcome.Applied).session.activity) + val gap = aidenApplyBotSessionEvent(afterQueued, at(afterQueued.seq + 2, AidenBotSessionEventPayload.Activity(null))) + assertEquals(AidenBotSessionEventOutcome.Refetch, gap) + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd android && JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:testDebugUnitTest --console=plain --tests 'sbtbiswas.AidenOnTheGo.features.bots.AidenBotSessionControllerTest'` +Expected: compilation FAILS. `aidenBotStatusLine`, `activitySupported` and the four-argument `send` are undefined. + +- [ ] **Step 3: Implement** + +In `AidenBotSessionController.kt`: + +1. `AidenBotSessionTransport`: change `send` to `suspend fun send(botId: String, text: String, key: UUID, whenBusy: AidenStreamInputMode? = null): AidenBotSessionSendResponse`. Change `AidenRemoteBotSessionTransport`'s override to `override suspend fun send(botId: String, text: String, key: UUID, whenBusy: AidenStreamInputMode?) = client.sendBotMessage(botId, text, key, whenBusy)`. +2. In `aidenApplyBotSessionEvent`'s `when`, before `AidenBotSessionEventPayload.Closed ->`: + ```kotlin + is AidenBotSessionEventPayload.Activity -> + AidenBotSessionEventOutcome.Applied(current.copy(seq = event.seq, activity = payload.activity)) + is AidenBotSessionEventPayload.Queued -> + AidenBotSessionEventOutcome.Applied(current.copy(seq = event.seq, queued = payload.queued)) + ``` +3. `AidenBotSessionUiState`: add the fields + ```kotlin + /** Revision 30: the Mac announces `bot-activity-v1` and this phone holds `bot:activity`. */ + val activitySupported: Boolean = false, + /** The Bot's Chat activity wording, from its detail. */ + val wording: AidenBotChatActivityWording = AidenBotChatActivityWording.FOLLOWS, + /** The session feed dropped and is reconnecting. */ + val streamDown: Boolean = false + ``` + and the property `/** Steer and Queue while the Bot works need `bot-activity-v1`. */ val offersBusyModes: Boolean get() = activitySupported && isRunning`. +4. Below `AidenBotSessionUiState`, add: + +```kotlin +/** What the Bot chat's status line shows (spec §12). */ +data class AidenBotStatusLineState(val display: AidenBotActivityDisplay, val elapsed: String?) + +/** The status line now, or null when it is hidden. */ +fun aidenBotStatusLine(ui: AidenBotSessionUiState, nowMs: Long): AidenBotStatusLineState? { + if (!ui.activitySupported || !ui.isRunning) return null + val session = ui.session ?: return null + val activity = session.activity + val elapsed = activity?.let { aidenBotElapsedLabel(it.startedAt, nowMs) } + if (ui.streamDown) return AidenBotStatusLineState(aidenBotActivityDisplay("reconnecting", ui.wording, null), elapsed) + // While the reply's words stream, the words are the signal (§5.2). + if (!session.partial.isNullOrEmpty()) return null + // Sent, and the first activity frame is on its way. + if (activity == null) return AidenBotStatusLineState(aidenBotActivityDisplay("preparing", ui.wording, null), null) + return AidenBotStatusLineState(aidenBotActivityDisplay(activity.phase, ui.wording, activity.app), elapsed) +} +``` + +5. `AidenBotSessionController`: + - Add the constructor parameter `activitySupported: Boolean = false` after `knownState`. + - Initialise the state with `AidenBotSessionUiState(knownState = knownState, activitySupported = activitySupported)`. + - Add: + ```kotlin + fun setActivitySupported(supported: Boolean) { + _state.update { it.copy(activitySupported = supported) } + } + + fun setWording(wording: AidenBotChatActivityWording) { + _state.update { it.copy(wording = wording) } + } + ``` +6. In `follow()`: + - Inside `collect { event -> … }`, add as the first line `if (_state.value.streamDown) _state.update { it.copy(streamDown = false) }`. + - Directly before `delay(reconnectDelayMillis)`, add: + ```kotlin + // Closed or dropped: while a turn runs the line says "Reconnecting to your Mac". + _state.update { it.copy(streamDown = true) } + ``` +7. In `send`: + - Replace `private var pendingSendText: String? = null` with `private var pendingSend: Pair? = null`. + - Replace `send` with: + +```kotlin + /** + * Sends [text]. Nothing is sent while the Bot needs an AI model or another send is in + * flight. A failed send keeps its key, so retrying the same text cannot post twice. While + * the Bot works on a Mac offering `bot-activity-v1`, [whenBusy] says Steer or Queue. + */ + suspend fun send(text: String, whenBusy: AidenStreamInputMode? = null): Boolean { + val trimmed = text.trim() + val current = _state.value + if (trimmed.isEmpty() || !current.canSend) return false + val mode = if (current.offersBusyModes) whenBusy else null + val pending = trimmed to mode + if (pendingSend != pending) { + keys.complete(SEND) + pendingSend = pending + } + val key = keys.key(SEND) + _state.update { it.copy(isSending = true, actionError = null) } + return try { + val receipt = transport.send(botId, trimmed, key, mode) + keys.complete(SEND) + pendingSend = null + applyStateView(receipt.stateView) + true + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + keys.failed(SEND, error) + if (aidenBotMacAnswered(error)) pendingSend = null + _state.update { it.copy(actionError = "Aiden couldn’t send that. Try again.") } + false + } finally { + _state.update { it.copy(isSending = false) } + } + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run the Step 2 command again. +Expected: PASS. That is the 6 new tests plus the existing 32. + +- [ ] **Step 5: Commit** + +```bash +git add android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionController.kt android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt +git commit -m "Android Bots: status line state, Reconnecting, queued messages and Steer/Queue in the session controller" +``` + +--- + +### Task 10: Android chat: the status line, the Steer/Queue split button, Queued bubbles and glancing faces + +**Files:** +- Modify: `android/.../features/bots/AidenBotActivity.kt` (status line composable), `AidenBotSessionScreen.kt`, `AidenBotSemanticAvatarView.kt`, `AidenBotCanonicalAvatarView.kt`, `AidenBotsHomeScreen.kt`, `android/.../features/chat/AidenComposerView.kt` +- Test: none new. Task 8's glance test and Task 9's state tests cover the logic. The JVM suite has no Compose UI tests, so this task ends with an emulator capture. + +**Interfaces:** +- Consumes: `aidenBotStatusLine`, `AidenBotStatusLineState`, `AidenBotActivityDisplay.word()`, `aidenBotNextAnnouncement` and `aidenBotFaceGlance` (Tasks 8–9). `AidenActivityMark`. `AidenRunInputSplitButton` (made `internal`). +- Produces: + - `@Composable fun AidenBotStatusLine(ui: AidenBotSessionUiState)` and `@Composable fun AidenBotQueuedBubble(message)`. + - The new parameters `AidenBotSemanticAvatarView(…, working: Boolean = false)` and `AidenBotCanonicalAvatarView(…, working: Boolean = false)`. + +- [ ] **Step 1: Share the split button** + +In `android/.../features/chat/AidenComposerView.kt`, change `private fun AidenRunInputSplitButton(` to `internal fun AidenRunInputSplitButton(`. Its option table stays private. No behaviour changes. + +- [ ] **Step 2: The status line and the Queued bubble** + +Append to `AidenBotActivity.kt`, with imports for `androidx.compose.animation.*`, `androidx.compose.foundation.layout.*`, `androidx.compose.material3.*`, `androidx.compose.runtime.*`, `androidx.compose.ui.Alignment`, `androidx.compose.ui.Modifier`, `androidx.compose.ui.semantics.*`, `androidx.compose.ui.unit.dp`, `kotlinx.coroutines.delay`, `sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMark`, `sbtbiswas.AidenOnTheGo.ui.theme.AidenMotion`, `sbtbiswas.AidenOnTheGo.ui.theme.AidenTheme`, `sbtbiswas.AidenOnTheGo.models.AidenBotQueuedMessage` and `sbtbiswas.AidenOnTheGo.models.AidenStreamInputMode`: + +```kotlin +/** + * The status line while a Bot works (spec §12): a native activity mark and the shared word, + * plus the elapsed time after 20 seconds. No steps and no Stop (Stop stays in the top bar). + * The live region announces the word at most once every 2 seconds. + */ +@Composable +fun AidenBotStatusLine(ui: AidenBotSessionUiState) { + val palette = AidenTheme.palette + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(ui.isRunning) { + while (ui.isRunning) { + now = System.currentTimeMillis() + delay(1_000) + } + } + val line = aidenBotStatusLine(ui, now) + var last by remember { mutableStateOf(null) } + if (line != null) last = line + AnimatedVisibility( + visible = line != null, + enter = fadeIn(AidenMotion.short()), + exit = fadeOut(AidenMotion.short()) + ) { + val shown = line ?: last ?: return@AnimatedVisibility + val word = shown.display.word() + var announced by remember { mutableStateOf(null) } + LaunchedEffect(word) { + val step = aidenBotNextAnnouncement(announced, word, System.currentTimeMillis()) + announced = step.shown + step.retryInMs?.let { + delay(it) + announced = aidenBotNextAnnouncement(announced, word, System.currentTimeMillis()).shown + } + } + val spoken = announced?.text ?: word + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 2.dp) + .semantics { + liveRegion = LiveRegionMode.Polite + contentDescription = spoken + } + ) { + AidenActivityMark(mark = shown.display.mark, size = 18.dp, color = palette.secondary) + Text( + text = shown.elapsed?.let { stringResource(R.string.bot_activity_status_elapsed, word, it) } ?: word, + style = MaterialTheme.typography.bodyMedium, + color = palette.secondary + ) + } + } +} + +/** The person's waiting message: faded with "Queued", or a steer shown as a normal bubble. */ +@Composable +fun AidenBotQueuedBubble(message: AidenBotQueuedMessage) { + val palette = AidenTheme.palette + val queued = message.mode == AidenStreamInputMode.QUEUE + val hint = stringResource(R.string.bot_session_queued_hint) + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { if (queued) stateDescription = hint } + ) { + Surface( + color = palette.accent.copy(alpha = if (queued) 0.45f else 1f), + shape = RoundedCornerShape(20.dp, 20.dp, 4.dp, 20.dp), + modifier = Modifier.widthIn(max = 320.dp) + ) { + Text( + message.text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp) + ) + } + if (queued) { + Text( + stringResource(R.string.bot_session_queued), + style = MaterialTheme.typography.labelSmall, + color = palette.secondary, + modifier = Modifier.padding(top = 2.dp, end = 4.dp) + ) + } + } +} +``` + +Also import `androidx.compose.foundation.shape.RoundedCornerShape` and `androidx.compose.ui.res.stringResource`. + +- [ ] **Step 3: Wire the session screen** + +In `AidenBotSessionScreen.kt`: + +1. Build the controller with `AidenBotSessionController(botId, AidenRemoteBotSessionTransport(cl), scope, knownState, activitySupported = serverInfo?.supportsBotActivity == true)`. +2. After `val ui by controller.state.collectAsStateWithLifecycle()`, add: + +```kotlin + val activitySupported = serverInfo?.supportsBotActivity == true + LaunchedEffect(controller, activitySupported) { controller.setActivitySupported(activitySupported) } + // The wording comes from the Bot's detail: cached first, then the Mac's. + LaunchedEffect(controller) { + coordinator.botCache.getBotDetail(botId)?.let { + controller.setWording(it.chatActivity?.wording ?: AidenBotChatActivityWording.FOLLOWS) + } + try { + val detail = cl.bot(botId) + coordinator.botCache.putBotDetail(detail) + controller.setWording(detail.chatActivity?.wording ?: AidenBotChatActivityWording.FOLLOWS) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Keep the cached or default wording; the line still works. + } + } + var busyMode by remember(botId) { mutableStateOf(AidenStreamInputMode.QUEUE) } + LaunchedEffect(ui.isRunning) { if (!ui.isRunning) busyMode = AidenStreamInputMode.QUEUE } +``` + +3. In the `LazyColumn`, after the `partial` item, add: + +```kotlin + ui.session?.queued.orEmpty().forEach { message -> + item(key = "queued-${message.submissionId}") { AidenBotQueuedBubble(message) } + } + item(key = "status") { AidenBotStatusLine(ui) } +``` + + Change the auto-scroll effect's keys to `LaunchedEffect(entries.size, partial, ui.isInterrupted, ui.needsModel, ui.session?.queued?.size, ui.session?.activity?.phase)`. +4. In `advancedBot?.let { bot -> AidenBotAdvancedSheet(…) }`, pass the two new Task 11 parameters: `chatActivityAvailable = activitySupported` and `onChatActivitySaved = { saved -> coordinator.botCache.putBotDetail(saved); controller.setWording(saved.chatActivity?.wording ?: AidenBotChatActivityWording.FOLLOWS) }`. Add this line in Task 11, once the parameters exist. +5. Replace the `AidenBotSessionComposer(…)` call and the composable: + +```kotlin + AidenBotSessionComposer( + draft = draft, + onDraftChange = { draft = it.take(AidenBotSessionWire.MAX_MESSAGE_LENGTH) }, + placeholder = aidenBotChatPlaceholder(identity.name), + enabled = ui.canSend, + busy = ui.offersBusyModes, + busyMode = busyMode, + onPickBusyMode = { busyMode = it }, + onSend = { + val text = draft + val mode = if (ui.offersBusyModes) busyMode else null + scope.launch { + if (controller.send(text, mode)) draft = "" + } + } + ) +``` + +```kotlin +@Composable +private fun AidenBotSessionComposer( + draft: String, + onDraftChange: (String) -> Unit, + placeholder: String, + enabled: Boolean, + busy: Boolean, + busyMode: AidenStreamInputMode, + onPickBusyMode: (AidenStreamInputMode) -> Unit, + onSend: () -> Unit +) { + val palette = AidenTheme.palette + Row( + verticalAlignment = Alignment.Bottom, + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 12.dp, vertical = 8.dp) + .testTag(AidenBotSessionTags.COMPOSER) + ) { + TextField( + value = draft, + onValueChange = onDraftChange, + enabled = enabled, + placeholder = { Text(placeholder) }, + colors = aidenTextFieldColors(), + shape = RoundedCornerShape(AidenUi.ComposerRadius), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + maxLines = 6, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + if (busy) { + // While the Bot works: Steer joins the reply, Queue sends after it (desktop and iOS parity). + AidenRunInputSplitButton( + mode = busyMode, + canSubmit = enabled && draft.isNotBlank(), + canChooseMode = enabled, + onSubmit = onSend, + onPickMode = onPickBusyMode + ) + } else { + FilledIconButton( + onClick = onSend, + enabled = enabled && draft.isNotBlank(), + shape = AidenShape.Button, + colors = IconButtonDefaults.filledIconButtonColors(containerColor = palette.accent, contentColor = MaterialTheme.colorScheme.onPrimary), + modifier = Modifier.size(AidenUi.MinimumTouchTarget) + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.bot_session_send)) + } + } + } +} +``` + +Add the import `sbtbiswas.AidenOnTheGo.features.chat.AidenRunInputSplitButton`. + +- [ ] **Step 4: Glancing faces** + +1. `AidenBotSemanticAvatarView.kt`: + - Add the parameter `working: Boolean = false` after `size`. + - Add the imports `androidx.compose.runtime.LaunchedEffect`, `mutableLongStateOf`, `remember`, `withFrameMillis` and `sbtbiswas.AidenOnTheGo.ui.theme.aidenReduceMotion`. + - Before the `Box`, add: + ```kotlin + // While its Bot works the canonical face glances; Reduce Motion holds the still pose. + val glancing = working && !aidenReduceMotion() + val clockMs = remember { mutableLongStateOf(0L) } + LaunchedEffect(glancing) { + if (!glancing) { + clockMs.longValue = 0L + return@LaunchedEffect + } + var startMs = Long.MIN_VALUE + while (true) { + withFrameMillis { frameMs -> + if (startMs == Long.MIN_VALUE) startMs = frameMs + clockMs.longValue = frameMs - startMs + } + } + } + ``` + - Replace the eye-drawing block inside `Canvas` with the following. The clock is read only in the draw phase, as in `AidenActivityMark`. + ```kotlin + // The one fixed eye mark: two slanted pills in the shared face colour. + val glance = if (glancing) aidenBotFaceGlance(clockMs.longValue) else AidenBotFaceGlance(0f, 1f) + val eyeWidth = w * 0.11f + val eyeHeight = h * 0.24f + val eyeTop = h * 0.42f + val shownHeight = eyeHeight * glance.blink + listOf(w * 0.40f, w * 0.58f).forEach { base -> + val left = base + glance.dxFraction * w + rotate(degrees = 12f, pivot = Offset(left + eyeWidth / 2f, eyeTop + eyeHeight / 2f)) { + drawRoundRect( + color = AidenBotAvatarColors.face, + topLeft = Offset(left, eyeTop + (eyeHeight - shownHeight) / 2f), + size = Size(eyeWidth, shownHeight), + cornerRadius = CornerRadius(eyeWidth / 2f, minOf(eyeWidth, shownHeight) / 2f) + ) + } + } + ``` +2. `AidenBotCanonicalAvatarView.kt`: + - Add `working: Boolean = false` to `AidenBotCanonicalAvatarView` and to `AidenBotAvatarLayer`, and pass it through both call sites. + - In `AidenBotAvatarLayer`'s `else` branch, pass `working = working` to `AidenBotSemanticAvatarView`. + - In its `if (bitmap != null)` branch, wrap the `Image` in a `Box(Modifier.size(size))` and add after it: + ```kotlin + // A photo can't glance; a small glance mark says the Bot is working. + if (working) { + Box( + Modifier + .align(Alignment.BottomEnd) + .size(size * 0.38f) + .clip(CircleShape) + .background(AidenTheme.palette.canvas), + contentAlignment = Alignment.Center + ) { + AidenActivityMark(mark = AidenActivityMarkKind.GLANCE, size = size * 0.3f, color = AidenTheme.palette.foreground) + } + } + ``` +3. `AidenBotsHomeScreen.kt`: + - Pass `working = row.isWorking` to `AidenBotCanonicalAvatarView`. + - Delete the `if (row.isWorking) { Box(… palette.success …) }` green dot. + - Move its `.semantics { contentDescription = workingLabel }` onto the avatar `Box`, as `Modifier.semantics { if (row.isWorking) stateDescription = workingLabel }`. + +- [ ] **Step 5: Build, test and look at it** + +Run: `cd android && JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:testDebugUnitTest --console=plain` +Expected: PASS (the full JVM suite). + +Install the debug build on a headless AVD (not the owner's phone), paired with a Mac on this branch. Check: +1. The status line walks through the phases. +2. While the Bot works, the composer shows the Queue/Steer split button. A queued message shows faded with "Queued". +3. In Bots home, a working Bot's face glances. +4. With *Remove animations* on, the face and mark hold still. + +Capture light and dark screenshots. + +- [ ] **Step 6: Commit** + +```bash +git add android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSemanticAvatarView.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCanonicalAvatarView.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/chat/AidenComposerView.kt +git commit -m "Android Bots: status line, Steer/Queue split button, Queued bubbles and glancing faces" +``` + +--- + +### Task 11: Android Advanced → Chat activity + +**Files:** +- Create: `android/.../features/bots/AidenBotChatActivityScreen.kt` +- Modify: `android/.../features/bots/AidenBotAdvancedScreen.kt`, `AidenBotSessionScreen.kt`, `AidenBotProfileScreen.kt`, `android/app/src/main/res/values/strings.xml` +- Test: create `android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt` + +**Interfaces:** +- Consumes: `AidenBotChatActivity*`, `AidenBotIdentityPatch(chatActivity =)` and `AidenRemoteClient.updateBotIdentity` (Task 8). `aidenBotActivityDisplay` (Task 8). +- Produces: + - `object AidenBotChatActivityPreview { FRAME_MILLIS; STILL_FRAME; frameIndex(elapsedMs); frame(index, level) }` and `data class AidenBotChatActivityPreviewFrame`. + - `data class AidenBotChatActivityUiState` and `class AidenBotChatActivityController(bot, save, onSaved) { state; suspend fun pick(next); fun dismissError() }`. + - The `@StringRes` extensions `label` and `detail` on the level and wording enums. + - `@Composable fun AidenBotChatActivityDialog(bot, client, onDismiss, onSaved)`. + - `AidenBotAdvancedSheet(…, chatActivityAvailable: Boolean = false, onChatActivitySaved: (AidenBotDetail) -> Unit = {})`. + +- [ ] **Step 1: Write the failing tests** + +Create `android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt`: + +```kotlin +package sbtbiswas.AidenOnTheGo.features.bots + +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import sbtbiswas.AidenOnTheGo.R +import sbtbiswas.AidenOnTheGo.models.* +import java.io.IOException + +class AidenBotChatActivityTest { + private val json = AidenBotWireJson.json + private val fixture: JsonObject by lazy { + val text = requireNotNull(javaClass.classLoader?.getResourceAsStream("contract.json")) + .bufferedReader().use { it.readText() } + json.parseToJsonElement(text).jsonObject + } + private val bot by lazy { json.decodeFromJsonElement(AidenBotDetail.serializer(), fixture.getValue("botDetail")) } + private val summaryFollows = AidenBotChatActivity(AidenBotChatActivityLevel.SUMMARY, AidenBotChatActivityWording.FOLLOWS) + private val summaryThinking = AidenBotChatActivity(AidenBotChatActivityLevel.SUMMARY, AidenBotChatActivityWording.THINKING) + + // Test 1 + @Test + fun aPickSavesAtOnceWithTheBotsRevisionAndTheNextPickUsesTheNewOne() = runTest { + val revisions = mutableListOf() + val saved = mutableListOf() + val controller = AidenBotChatActivityController( + bot.copy(revision = "rev_1"), + save = { next, revision -> + revisions += revision + bot.copy(revision = if (revision == "rev_1") "rev_2" else "rev_3", chatActivity = next) + }, + onSaved = { saved += it.revision } + ) + assertEquals(AidenBotChatActivity.DEFAULT, controller.state.value.value) + controller.pick(summaryFollows) + controller.pick(summaryThinking) + assertEquals(listOf("rev_1", "rev_2"), revisions) + assertEquals(summaryThinking, controller.state.value.value) + assertEquals(listOf("rev_2", "rev_3"), saved) + controller.pick(summaryThinking) + assertEquals("the current choice sends nothing", 2, revisions.size) + } + + // Test 2 (Review Focus 5) + @Test + fun aFailedSaveReturnsToTheSavedChoiceAndSaysSo() = runTest { + val revisions = mutableListOf() + var fail = true + val controller = AidenBotChatActivityController( + bot.copy(revision = "rev_1", chatActivity = AidenBotChatActivity.DEFAULT), + save = { next, revision -> + revisions += revision + if (fail) throw IOException("offline") + bot.copy(revision = "rev_2", chatActivity = next) + } + ) + val detailed = AidenBotChatActivity(AidenBotChatActivityLevel.DETAILED, AidenBotChatActivityWording.FOLLOWS) + controller.pick(detailed) + assertEquals(AidenBotChatActivity.DEFAULT, controller.state.value.value) + assertTrue(controller.state.value.saveFailed) + assertFalse(controller.state.value.isSaving) + + fail = false + controller.pick(detailed) + assertEquals("a failed save leaves the revision where it was", listOf("rev_1", "rev_1"), revisions) + assertEquals(detailed, controller.state.value.value) + assertFalse(controller.state.value.saveFailed) + } + + // Test 3 + @Test + fun previewsShowWhatEachLevelLooksLikeOnTheMac() { + val reply = AidenBotChatActivityPreview.FRAME_MILLIS.size - 1 + assertEquals("reading", AidenBotChatActivityPreview.frame(1, AidenBotChatActivityLevel.QUIET).phase) + assertEquals(emptyList(), AidenBotChatActivityPreview.frame(1, AidenBotChatActivityLevel.QUIET).steps) + assertEquals( + listOf(R.string.bot_chat_activity_preview_step_read, R.string.bot_chat_activity_preview_step_search), + AidenBotChatActivityPreview.frame(2, AidenBotChatActivityLevel.DETAILED).steps + ) + assertFalse(AidenBotChatActivityPreview.frame(reply, AidenBotChatActivityLevel.QUIET).recapShown) + val summary = AidenBotChatActivityPreview.frame(reply, AidenBotChatActivityLevel.SUMMARY) + assertTrue(summary.recapShown) + assertFalse(summary.recapOpen) + val detailed = AidenBotChatActivityPreview.frame(reply, AidenBotChatActivityLevel.DETAILED) + assertTrue(detailed.recapOpen) + assertEquals(3, detailed.steps.size) + assertNull("the reply has landed", detailed.phase) + + assertEquals(0, AidenBotChatActivityPreview.frameIndex(0)) + assertEquals(1, AidenBotChatActivityPreview.frameIndex(1_500)) + assertEquals(reply, AidenBotChatActivityPreview.frameIndex(6_500)) + assertEquals("the turn loops", 0, AidenBotChatActivityPreview.frameIndex(8_800)) + assertTrue( + "Reduce Motion's still frame shows the line mid-turn", + AidenBotChatActivityPreview.frame(AidenBotChatActivityPreview.STILL_FRAME, AidenBotChatActivityLevel.QUIET).phase != null + ) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd android && JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:testDebugUnitTest --console=plain --tests 'sbtbiswas.AidenOnTheGo.features.bots.AidenBotChatActivityTest'` +Expected: compilation FAILS. `AidenBotChatActivityController` and `AidenBotChatActivityPreview` are undefined. + +- [ ] **Step 3: Strings** + +Add to `strings.xml`, below the Task 8 activity strings: + +```xml + + Chat activity + What you see in %1$s’s chat on your Mac while it works. Changes save as you pick them. + How much to show + What the status line says + On iPhone and Android + Always just the line, with the same words. + Recommended + Quiet + One line that says what it’s doing. + Summary + Adds a short recap above each reply. + Detailed + Also shows its last few steps while it works. + Follows the work + Says what it’s doing, like Reading or Searching the web. + Always “Thinking” + One calm word, whatever it’s doing. + %1$s · %2$s + Aiden couldn’t save that choice. + Preview + Plan dinners for this week + Here’s a plan for the week. + Worked for 18s · read 1 file, searched the web, edited 1 file + Read 1 file + Searched the web + Edited 1 file +``` + +- [ ] **Step 4: The controller, previews and page** + +Create `android/.../features/bots/AidenBotChatActivityScreen.kt`: + +```kotlin +package sbtbiswas.AidenOnTheGo.features.bots + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import sbtbiswas.AidenOnTheGo.R +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMark +import sbtbiswas.AidenOnTheGo.models.* +import sbtbiswas.AidenOnTheGo.networking.AidenRemoteClient +import sbtbiswas.AidenOnTheGo.ui.theme.AidenConnectedColumn +import sbtbiswas.AidenOnTheGo.ui.theme.AidenGroupCard +import sbtbiswas.AidenOnTheGo.ui.theme.AidenTheme +import sbtbiswas.AidenOnTheGo.ui.theme.AidenUi +import sbtbiswas.AidenOnTheGo.ui.theme.aidenReadableWidth +import sbtbiswas.AidenOnTheGo.ui.theme.aidenReduceMotion + +@get:StringRes +val AidenBotChatActivityLevel.label: Int + get() = when (this) { + AidenBotChatActivityLevel.QUIET -> R.string.bot_chat_activity_level_quiet + AidenBotChatActivityLevel.SUMMARY -> R.string.bot_chat_activity_level_summary + AidenBotChatActivityLevel.DETAILED -> R.string.bot_chat_activity_level_detailed + } + +@get:StringRes +val AidenBotChatActivityLevel.detail: Int + get() = when (this) { + AidenBotChatActivityLevel.QUIET -> R.string.bot_chat_activity_level_quiet_detail + AidenBotChatActivityLevel.SUMMARY -> R.string.bot_chat_activity_level_summary_detail + AidenBotChatActivityLevel.DETAILED -> R.string.bot_chat_activity_level_detailed_detail + } + +@get:StringRes +val AidenBotChatActivityWording.label: Int + get() = when (this) { + AidenBotChatActivityWording.FOLLOWS -> R.string.bot_chat_activity_wording_follows + AidenBotChatActivityWording.THINKING -> R.string.bot_chat_activity_wording_thinking + } + +@get:StringRes +val AidenBotChatActivityWording.detail: Int + get() = when (this) { + AidenBotChatActivityWording.FOLLOWS -> R.string.bot_chat_activity_wording_follows_detail + AidenBotChatActivityWording.THINKING -> R.string.bot_chat_activity_wording_thinking_detail + } + +/** Advanced's trailing value: "Quiet · Follows the work". */ +@Composable +fun aidenBotChatActivitySummary(value: AidenBotChatActivity?): String { + val resolved = value ?: AidenBotChatActivity.DEFAULT + return stringResource(R.string.bot_chat_activity_value, stringResource(resolved.level.label), stringResource(resolved.wording.label)) +} + +/** One frame of the scripted turn, as a level shows it on the Mac. */ +data class AidenBotChatActivityPreviewFrame( + /** The line's phase, or null on the reply frame. */ + val phase: String?, + @StringRes val steps: List, + val recapShown: Boolean, + val recapOpen: Boolean +) + +/** Think, read, search, edit, reply: the desktop page's script. */ +object AidenBotChatActivityPreview { + val FRAME_MILLIS = longArrayOf(1_400, 1_600, 1_600, 1_400, 2_800) + /** Reduce Motion's still frame: mid-turn, with the line showing. */ + const val STILL_FRAME = 2 + private val PHASES = listOf("thinking", "reading", "searching", "editing", null) + private val STEPS = listOf( + R.string.bot_chat_activity_preview_step_read, + R.string.bot_chat_activity_preview_step_search, + R.string.bot_chat_activity_preview_step_edit + ) + + fun frameIndex(elapsedMs: Long): Int { + val total = FRAME_MILLIS.sum() + var time = ((elapsedMs % total) + total) % total + FRAME_MILLIS.forEachIndexed { index, duration -> + if (time < duration) return index + time -= duration + } + return FRAME_MILLIS.size - 1 + } + + fun frame(index: Int, level: AidenBotChatActivityLevel): AidenBotChatActivityPreviewFrame { + val clamped = index.coerceIn(0, PHASES.size - 1) + val phase = PHASES[clamped] + if (phase != null) { + return AidenBotChatActivityPreviewFrame( + phase, + if (level == AidenBotChatActivityLevel.DETAILED) STEPS.take(clamped) else emptyList(), + recapShown = false, + recapOpen = false + ) + } + return when (level) { + AidenBotChatActivityLevel.QUIET -> AidenBotChatActivityPreviewFrame(null, emptyList(), recapShown = false, recapOpen = false) + AidenBotChatActivityLevel.SUMMARY -> AidenBotChatActivityPreviewFrame(null, emptyList(), recapShown = true, recapOpen = false) + AidenBotChatActivityLevel.DETAILED -> AidenBotChatActivityPreviewFrame(null, STEPS, recapShown = true, recapOpen = true) + } + } +} + +data class AidenBotChatActivityUiState( + val value: AidenBotChatActivity, + val revision: String, + val isSaving: Boolean = false, + val saveFailed: Boolean = false +) + +/** Saves each pick at once (no Save button). A failed save returns to the saved choice. */ +class AidenBotChatActivityController( + bot: AidenBotDetail, + private val save: suspend (AidenBotChatActivity, String) -> AidenBotDetail, + private val onSaved: (AidenBotDetail) -> Unit = {} +) { + private val _state = MutableStateFlow(AidenBotChatActivityUiState(bot.chatActivity ?: AidenBotChatActivity.DEFAULT, bot.revision)) + val state: StateFlow = _state.asStateFlow() + + suspend fun pick(next: AidenBotChatActivity) { + val current = _state.value + if (next == current.value || current.isSaving) return + _state.value = current.copy(value = next, isSaving = true, saveFailed = false) + try { + val saved = save(next, current.revision) + _state.value = AidenBotChatActivityUiState(saved.chatActivity ?: next, saved.revision) + onSaved(saved) + } catch (error: CancellationException) { + _state.value = current + throw error + } catch (_: Exception) { + _state.value = current.copy(saveFailed = true) + } + } + + fun dismissError() { + _state.update { it.copy(saveFailed = false) } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AidenBotChatActivityDialog( + bot: AidenBotDetail, + client: AidenRemoteClient?, + onDismiss: () -> Unit, + onSaved: (AidenBotDetail) -> Unit +) { + val palette = AidenTheme.palette + val scope = rememberCoroutineScope() + val controller = remember(bot.id, client) { + AidenBotChatActivityController( + bot, + save = { next, revision -> + val cl = client ?: throw IllegalStateException("Not connected") + cl.updateBotIdentity(bot.id, revision, AidenBotIdentityPatch(chatActivity = next)) + }, + onSaved = onSaved + ) + } + val state by controller.state.collectAsState() + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Scaffold( + containerColor = palette.canvas, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.bot_chat_activity_title), fontWeight = FontWeight.SemiBold) }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back), tint = palette.foreground) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = palette.canvas, titleContentColor = palette.foreground) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .aidenReadableWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = AidenUi.ScreenGutter, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(AidenUi.SectionGap) + ) { + Text(stringResource(R.string.bot_chat_activity_description, bot.name), color = palette.secondary, style = MaterialTheme.typography.bodyMedium) + AidenBotChatActivityGroup(stringResource(R.string.bot_chat_activity_level_title)) { + val levels = AidenBotChatActivityLevel.entries + levels.forEachIndexed { index, level -> + AidenBotChatActivityChoice( + index = index, + count = levels.size, + title = stringResource(level.label), + detail = stringResource(level.detail), + recommended = level == AidenBotChatActivityLevel.QUIET, + selected = state.value.level == level, + enabled = !state.isSaving, + onPick = { scope.launch { controller.pick(state.value.copy(level = level)) } } + ) { AidenBotChatActivityPreviewCard(level = level, wording = state.value.wording) } + } + } + AidenBotChatActivityGroup(stringResource(R.string.bot_chat_activity_wording_title)) { + val wordings = AidenBotChatActivityWording.entries + wordings.forEachIndexed { index, wording -> + AidenBotChatActivityChoice( + index = index, + count = wordings.size, + title = stringResource(wording.label), + detail = stringResource(wording.detail), + recommended = false, + selected = state.value.wording == wording, + enabled = !state.isSaving, + onPick = { scope.launch { controller.pick(state.value.copy(wording = wording)) } } + ) { AidenBotChatActivityPreviewCard(level = AidenBotChatActivityLevel.QUIET, wording = wording) } + } + } + AidenBotChatActivityGroup(stringResource(R.string.bot_chat_activity_phones_title)) { + Text(stringResource(R.string.bot_chat_activity_phones_description), color = palette.secondary, style = MaterialTheme.typography.bodySmall) + AidenBotChatActivityPreviewCard(level = AidenBotChatActivityLevel.QUIET, wording = state.value.wording) + } + if (state.saveFailed) { + Text(stringResource(R.string.bot_chat_activity_save_failed), color = palette.danger, style = MaterialTheme.typography.bodySmall) + } + } + } + } +} + +@Composable +private fun AidenBotChatActivityGroup(title: String, content: @Composable ColumnScope.() -> Unit) { + val palette = AidenTheme.palette + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.labelLarge, color = palette.secondary, modifier = Modifier.padding(horizontal = 4.dp)) + AidenConnectedColumn(content = content) + } +} + +/** A radio row: the radio and the tonal fill show the choice; there is no border. */ +@Composable +private fun AidenBotChatActivityChoice( + index: Int, + count: Int, + title: String, + detail: String, + recommended: Boolean, + selected: Boolean, + enabled: Boolean, + onPick: () -> Unit, + preview: @Composable () -> Unit +) { + val palette = AidenTheme.palette + AidenGroupCard( + index = index, + count = count, + selected = selected, + role = Role.RadioButton, + enabled = enabled, + onClick = onPick, + verticalAlignment = Alignment.Top + ) { + RadioButton(selected = selected, onClick = null, colors = RadioButtonDefaults.colors(selectedColor = palette.accent)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, color = palette.foreground, style = MaterialTheme.typography.bodyLarge) + if (recommended) { + Text(stringResource(R.string.bot_chat_activity_recommended), color = palette.secondary, style = MaterialTheme.typography.labelSmall) + } + } + Text(detail, color = palette.secondary, style = MaterialTheme.typography.bodySmall) + preview() + } + } +} + +/** A small chat replaying one scripted turn; it holds its still frame under Reduce Motion. */ +@Composable +fun AidenBotChatActivityPreviewCard(level: AidenBotChatActivityLevel, wording: AidenBotChatActivityWording) { + val palette = AidenTheme.palette + val reduceMotion = aidenReduceMotion() + var index by remember { mutableIntStateOf(AidenBotChatActivityPreview.STILL_FRAME) } + LaunchedEffect(reduceMotion) { + if (reduceMotion) { + index = AidenBotChatActivityPreview.STILL_FRAME + return@LaunchedEffect + } + val start = System.currentTimeMillis() + while (true) { + index = AidenBotChatActivityPreview.frameIndex(System.currentTimeMillis() - start) + delay(200) + } + } + val frame = AidenBotChatActivityPreview.frame(index, level) + val previewLabel = stringResource(R.string.bot_chat_activity_preview) + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(10.dp) + .clearAndSetSemantics { contentDescription = previewLabel } + ) { + Text( + stringResource(R.string.bot_chat_activity_preview_prompt), + style = MaterialTheme.typography.labelMedium, + color = palette.foreground, + modifier = Modifier + .align(Alignment.End) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) + if (frame.recapShown) { + Text( + (if (frame.recapOpen) "▾ " else "▸ ") + stringResource(R.string.bot_chat_activity_preview_recap), + style = MaterialTheme.typography.labelSmall, + color = palette.secondary + ) + } + frame.steps.forEach { step -> + Text(stringResource(step), style = MaterialTheme.typography.labelSmall, color = palette.secondary, modifier = Modifier.padding(start = 14.dp)) + } + val phase = frame.phase + if (phase != null) { + val shown = aidenBotActivityDisplay(phase, wording, null) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + AidenActivityMark(mark = shown.mark, size = 14.dp, active = !reduceMotion, color = palette.secondary) + Text(shown.word(), style = MaterialTheme.typography.labelMedium, color = palette.secondary) + } + } else { + Text(stringResource(R.string.bot_chat_activity_preview_reply), style = MaterialTheme.typography.labelMedium, color = palette.foreground) + } + } +} +``` + +- [ ] **Step 5: The Advanced row and its callers** + +In `AidenBotAdvancedScreen.kt`'s `AidenBotAdvancedSheet`: +1. Add the parameters `chatActivityAvailable: Boolean = false` and `onChatActivitySaved: (AidenBotDetail) -> Unit = {}` after `onSaved`. +2. Add the state: + ```kotlin + // A Chat activity save moves the Bot's revision; Advanced's own Save uses the newest. + var latestBot by remember(bot.id) { mutableStateOf(bot) } + var showingChatActivity by remember { mutableStateOf(false) } + ``` + Change the Save action's call to `aidenBotSaveAdvanced(cl, latestBot, current, cat)`. +3. After the Access `AidenBotAdvancedSection { … }`, inside the `else ->` branch, add: + ```kotlin + if (chatActivityAvailable) { + AidenBotAdvancedSection(title = stringResource(R.string.bot_chat_activity_title)) { + AidenConnectedColumn { + AidenGroupCard(index = 0, count = 1, onClick = { showingChatActivity = true }) { + Text(stringResource(R.string.bot_chat_activity_title), color = palette.foreground, modifier = Modifier.weight(1f)) + Text(aidenBotChatActivitySummary(latestBot.chatActivity), color = palette.secondary, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + ``` +4. After the `Dialog { … }` block, still inside `AidenBotAdvancedSheet`, add: + ```kotlin + if (showingChatActivity) { + AidenBotChatActivityDialog( + bot = latestBot, + client = client, + onDismiss = { showingChatActivity = false }, + onSaved = { saved -> + latestBot = saved + onChatActivitySaved(saved) + } + ) + } + ``` +5. `AidenBotSessionScreen.kt`: pass `chatActivityAvailable = activitySupported` and `onChatActivitySaved = { saved -> coordinator.botCache.putBotDetail(saved); controller.setWording(saved.chatActivity?.wording ?: AidenBotChatActivityWording.FOLLOWS) }` (the line Task 10 Step 3.4 deferred). +6. `AidenBotProfileScreen.kt`: pass `chatActivityAvailable = serverInfo?.supportsBotActivity == true` and `onChatActivitySaved = { saved -> accept(saved) }`. + +- [ ] **Step 6: Run the tests and look at it** + +Run: `cd android && JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:testDebugUnitTest --console=plain` +Expected: PASS (the full suite, including the 3 new tests). + +On the headless AVD, check: +1. Advanced shows "Chat activity Quiet · Follows the work". +2. Picking Summary saves at once, and the Mac's next reply shows the recap. +3. Previews loop, and hold their still frame with *Remove animations* on. +4. Against a revision-29 Mac the row is absent. + +Capture light and dark screenshots. + +- [ ] **Step 7: Commit** + +```bash +git add android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotAdvancedScreen.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotProfileScreen.kt android/app/src/main/res/values/strings.xml android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt +git commit -m "Android Bots: Advanced → Chat activity with looping previews, saved on pick" +``` + +--- + +### Task 12: Docs, memory and full verification + +**Files:** +- Modify: `docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md` (§11, §12, §17), `docs/plans/README.md`, `.memory/bot-chat-activity.md` (created by slice B), `.memory/bots-rework-ui.md` + +- [ ] **Step 1: Record the rulings in the spec** + +In §11, after the "Saving the setting" bullet, add: + +```markdown +- **Device opt-in (plan ruling).** Older phones' decoders refuse unknown session keys and event types, so the token alone can't gate what the host sends. A device negotiates `bot:activity` (no authority, any device type, offered only while `bot-activity-v1` is announced), like revision 27's `bot:cards`. Holders receive `activity` and `queued` (snapshot keys and frames) and Bot detail `chatActivity`. Other devices get a no-op `state` frame at those seqs. +- **Queued (plan ruling).** A `queued` frame and snapshot list carry the person's own waiting messages `{ submissionId, mode: "steer" | "queue", text }` (at most 20). Routine and hidden inputs never appear. Phones have no Remove (follow-up). +``` + +In §12, after "Faces", add: + +```markdown +- **Plan rulings.** + - The line shows from the send (`preparing` until the first frame). + - Elapsed time compares the Mac's `startedAt` with the phone's clock, and is hidden when negative. + - Phones don't show the 2-minute note. + - The Steer/Queue pill defaults to Queue, and Stop stays where it is. + - Previews illustrate the Mac's levels, and hold a still frame under Reduce Motion. + - A photo avatar gets a glance badge. +``` + +In §17, add `- Pi Durable withdraw on phones: no Remote withdraw route in D; the Queued bubble has no Remove on phones (follow-up).` + +- [ ] **Step 2: Plans index** + +In `docs/plans/README.md`'s "Bot chat activity…" row, change `D phones (Remote revision 30, \`bot-activity-v1\`)` to `D phones ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-d-phones.md): Remote revision 30 provisional, \`bot-activity-v1\` + negotiable \`bot:activity\`)`. + +- [ ] **Step 3: Project memory** + +Append a "## Slice D (phones)" section to `.memory/bot-chat-activity.md`. It must say: +- What crosses the wire: the shapes, the bounds and `bot:activity`. +- That the Remote projector reuses B's `liveBotActivity`/`liveQueuedMessages` and stays silent without `turnIntents`. +- That `botTurnIntents` (`main/services/bot-runtime/bot-turn-intents-main.ts`) is the one Stop/Resume source for the desktop projection and Remote. +- Where each platform's phase table lives, and the fixture that pins them (`botActivity.phases`). +- The iOS rule that there are no new Swift files. +- The Android strings and the elapsed-English exception. +- The test files. + +Add to `.memory/bots-rework-ui.md`'s "Feature tokens and revision" list: + +```markdown +- Revision **30** (2026-10-14, Bot chat activity on phones, provisional, re-claim at merge; `main` was 29): token `bot-activity-v1`, negotiable `bot:activity` gating the `activity` and `queued` session frames and snapshot keys plus Bot detail `chatActivity`; `PATCH /bots/{id}` accepts `chatActivity` and `POST /bots/{id}/messages` accepts `whenBusy: steer|queue` only while the token is announced. Details: `.memory/bot-chat-activity.md`. +``` + +- [ ] **Step 4: Full verification** + +Run each of these. All must PASS. + +```bash +npm run type-check +npm run test:aiden-remote +npm run test:bot-runtime +npm run test:bots +npm run test:ci-policy +xcodebuild test -quiet -project ios/AidenOnTheGo.xcodeproj -scheme AidenOnTheGo -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 17 Pro' CODE_SIGNING_ALLOWED=NO -derivedDataPath /private/tmp/claude-501/aiden-ios-dd -parallel-testing-enabled NO -only-testing:AidenOnTheGoTests +cd android && JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:testDebugUnitTest --console=plain +``` + +Then a real end-to-end pass: +1. Pair a phone with a Mac running this branch. Send to a Bot that reads a file and runs a command. +2. On the phone: "Getting ready" → "Reading" → "Running a command" → the reply. +3. Press Stop on the Mac: the phone reads "Stopping". +4. Toggle Wi-Fi: the phone reads "Reconnecting to your Mac", then recovers. +5. Queue a message: it shows faded with "Queued" until its turn. +6. Change Chat activity on the phone: the Mac's next reply follows it. +7. Pair the same phone with a revision-29 Mac (`main`): chats behave as today, and Advanced has no Chat activity row. + +- [ ] **Step 5: Commit** + +```bash +git add docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md docs/plans/README.md .memory/bot-chat-activity.md .memory/bots-rework-ui.md +git commit -m "Docs: Bot chat activity slice D rulings, plan index and memory" +``` + +At merge: if `main` has moved past revision 29, change every `30` this plan introduced to the next free revision. Those places are `AIDEN_REMOTE_CONTRACT_REVISION`, the fixture's two `contractRevision` lines, the four test assertions from Task 1, the fixture parser's `< 30` check, and the docs. Update iOS, Android and the fixture together (AGENTS.md). + +--- + +## Self-review + +- **Spec coverage:** + - §2 decisions 3, 4 and 11: Tasks 4–11. + - §4 D row: Tasks 1–3. + - §5 words, marks and ids: Task 1 fixture, Task 4 (iOS) and Task 8 (Android) tables. + - §5.2: unknown phase → working; elapsed after 20 s; no Stop on the phone line. Phones skip the 2-minute note (Ruling 4). + - §7 on phones: Tasks 7 and 11. + - §11: revision, token, `activity` frame and snapshot (Task 2), one-seq rule (Task 2 test 2), `app` ≤ 40 (Task 1), allow-list and privacy fixture (Tasks 1, 4 and 8), detail `chatActivity` and `PATCH` (Task 3), `whenBusy` (Tasks 1–2), older Macs (Tasks 3, 7 and 11). + - §12: status line, wording, elapsed and Reconnecting (Tasks 5 and 9), Steer/Queue and Queued (Tasks 5, 6, 9 and 10), faces (Tasks 6 and 10), Chat activity page (Tasks 7 and 11), Android strings (Tasks 8, 10 and 11). + - §14: announcement throttle (Tasks 5 and 8), Reduce Motion (Tasks 6, 7, 10 and 11). + - §16 iOS/Android rows: decoder tests (Tasks 4 and 8), model/controller tests (Tasks 5 and 9), Chat activity save (Tasks 7 and 11). The New Bot model row was slice A. +- **Placeholders:** none. Every code step shows the code, and every test step shows the test. +- **Type consistency:** + - TS `whenBusy` is `"steer" | "queue"` on the wire, and `"steer" | "followUp"` at the runtime. + - Swift `AidenStreamInputMode` and Kotlin `AidenStreamInputMode` reuse the chat enums (`steer`/`queue`). + - `AidenBotChatActivity.standard` (Swift) and `.DEFAULT` (Kotlin) are both Quiet/Follows. + - Phase ids are strings everywhere. Swift `Display.phase` is a `String`; Kotlin `AidenBotActivityDisplay.phase` is the enum (`.id` is the string). +- **Review Focus:** all five lines have pinned tests (Tasks 2, 3, 4, 5, 7, 8, 9 and 11, as listed). diff --git a/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md b/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md index cdfc70751..74139b379 100644 --- a/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md +++ b/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md @@ -292,6 +292,12 @@ Revision 30 is claimed at merge time, after whatever `main` holds then. The toke - **Bot detail** gains `chatActivity`. - **Steer and Queue.** `POST /bots/{id}/messages` accepts an optional `whenBusy: "steer" | "queue"` on hosts announcing the token (mapped to `steer`/`followUp`). Without it, behaviour is today's queue. - **Saving the setting.** `PATCH /bots/{id}` accepts `chatActivity` (exact keys, enum values) on hosts announcing the token. Phones send it only to such hosts. Older hosts reject unknown keys, so the gate is required. +- **Device opt-in (plan ruling).** Older phones' decoders refuse unknown session keys and event types, so the token alone can't gate what the host sends. A device negotiates `bot:activity` (no authority, any device type, offered only while `bot-activity-v1` is announced), like revision 27's `bot:cards`. Holders receive `activity` and `queued` (snapshot keys and frames), Bot detail `chatActivity`, and the `submissionId` below. Other devices get a no-op `state` frame at those seqs, and every Bot-detail-returning route (create, GET, PATCH and avatar DELETE; avatar PUT returns the asset metadata, not a detail) strips `chatActivity` for them at one choke point. The grant is persisted per device (a re-paired device negotiates again), and an older state file without it still loads. +- **Queued (plan ruling).** A `queued` frame and snapshot list carry the person's own waiting messages `{ submissionId, mode: "steer" | "queue", text }` (at most 20, text at most 32,000 code points, ids unique). Routine and hidden inputs never appear (R1): the list is built by inclusion from ids the desktop's fail-closed classifier marked as the person's own. Photo-only inputs have no text and are omitted (R11). Phones have no Remove (follow-up). +- **`submissionId` on user entries (R16, plan ruling).** For `bot:activity` devices a transcript `message` entry with `role: "user"` may carry `submissionId`: the id the same message had as the send receipt and in the queued list. Phones settle their optimistic bubble by it. It is never on a routine's input (`label`) or a question answer, and omitted when the Mac no longer knows it. The tag is sticky while the entry is in the projected view (eviction never changes an old entry, so no ghost bubble reaches any phone); for non-holders it is stripped at the existing choke point. +- **Stop and Resume.** `botTurnIntents` (`stopping`, `stopCancelled`, `resuming`) is the one source for the desktop projection and Remote. Remote Stop announces `stopping` (and `stopCancelled` when it fails); a Resume is announced only from the runtime's `onResumed`, never before `runtime.resume` (R2, R3, R4). Phones therefore read "Stopping" and "Picking up where it left off" exactly as the Mac does. +- **Bounds on the wire.** `app` at most 40 code points; `startedAt` an integer 0 to 2^53-1; `phase` matches `^[a-z][a-z_]{0,31}$` and is rendered as `working` when unknown; the `activity` object has exactly `phase`, `app?`, `startedAt`. +- **Revision.** 30 is provisional and must be re-claimed at merge, after whatever `main` holds then (every place that carries the number is listed in `.memory/bot-chat-activity.md`). - **Fixtures.** The privacy fixture asserts that an `activity` frame never carries keys outside the allow-list. Fixtures are shared across TS, Swift and Kotlin. - **Older Macs.** Phones paired with an older Mac keep today's behaviour and hide the Chat activity row. @@ -305,7 +311,29 @@ Both platforms (`AidenBotSessionChatView`/`AidenBotSessionModel`, `AidenBotSessi - **Chat activity page.** On iOS it is a native `Form` with radio rows and a looping preview per row; on Android it uses the Material 3 Expressive layer. Both pause their previews under Reduce Motion. The page saves through `PATCH /bots/{id}`. - **Steer and Queue.** While the Bot works, the composer offers Steer and Queue like normal chats (the iOS busy pill, Android's equivalent). Queued messages show faded with "Queued". - **Faces.** In the Bots home list, a running Bot's face uses the glance animation. -- **Android localization.** Copy goes in `strings.xml`, following the localization conventions. +- **Plan rulings.** + - The line shows from the send (`preparing` until the first frame). + - It hides only while reply text streams and the phase is writing or thinking (or none yet); a tool phase keeps it over narration text (R7). Whitespace-only text does not count as a reply. + - Elapsed time compares the Mac's `startedAt` with the phone's clock, and is hidden when negative. + - Phones don't show the 2-minute note. + - The Steer/Queue pill defaults to Queue, and Stop stays where it is. + - Previews illustrate the Mac's levels, and hold a still frame under Reduce Motion. The still frame depends on the level: Summary holds the reply with its recap, Quiet and Detailed hold a mid-turn frame. + - A photo avatar gets a glance badge. Under Reduce Motion or Low Power a canonical face also keeps the badge's still pose, so a working Bot stays distinguishable without motion (R19; shipped on Android, iOS is a follow-up). + - Running-step words in the previews are the Mac's phase words, not their own copy. +- **Optimistic, never waiting to connect (R14, R18).** Every Bot action on a phone applies at once and reconciles with the Mac later. + - Send shows the bubble (faded "Sending…") and "Getting ready" immediately, and goes through the outbox below. + - Stop shows "Stopping" at once; Resume shows "Picking up where it left off" at once; Retry goes through the outbox as a fresh optimistic send; Dismiss, question answers, approvals and routine proposals apply at once. Each rolls back with an error when it fails, and the rollback checks whether that field changed since the tap, not the transcript cursor. + - Steer/Queue and Chat activity picks apply at once and roll back on failure. A pick made while the phone is still connecting is held for up to 8 s, then sent or rolled back. Chat activity picks coalesce (three taps send two requests), are not queued durably, and recover a `revision_conflict` by refreshing the Bot and adopting the Mac's choice. + - Opening Bots home, a chat, Advanced or Chat activity never blocks on the connection: cached data or skeleton rows and bubbles show at once, with a quiet "Connecting…" hint and no spinner. The Chat activity row opens offline from the cached Bot. + - Every Bot request is bounded to 12 s, because the phone's URL session otherwise waits minutes on a dead link. A timed-out Chat activity save refreshes the Bot, so a late success is shown as saved rather than rolled back. +- **Outbox (R17).** A message sent while the phone is not connected, or before the Mac answers, waits in a per-Bot outbox with its idempotency key. + - It survives leaving the chat, a Bot switch and a relaunch. On iOS it is a file under Application Support (`BotOutbox-v1`) with file protection `completeUntilFirstUserAuthentication`, excluded from backup; on Android it is a file under `noBackupFilesDir` (never backed up). Both are keyed by the installation identity (instance and device), so a re-pair never restores old messages. + - Privacy: unsent message text is on disk for up to 24 hours. It is purged on unpair, on revoked credentials and on access loss (a 401/403 on the stream or reads), and only the per-Bot outbox file is read back. + - Limits: at most 20 messages and a byte budget (10 MB iOS, 4 MB Android). An over-limit send is refused visibly; the outbox never deletes older messages to make room. + - After 24 hours an unsent message becomes "Couldn't send · Try again"; it is never silently dropped, and neither is an exhausted retry. Ambiguous failures (I/O errors, 5xx, 408, 429, 409 `idempotency_in_flight`) keep the key and retry with backoff (1 s, then 4 s); a definite refusal fails the message, and Try again uses a new key. + - A message is frozen when first attempted (text and `whenBusy`), so a replay is byte-exact and never hits `idempotency_conflict`. + - Reconcile is by `submissionId` (receipt, queued list and the user entry), with text only as a fallback for Macs that omit it. Each transcript entry settles one message, once; an unsettled message is hidden when echoed and never removed. Order is first in, first out: a deferred head blocks later messages. +- **Android localization.** Copy goes in `strings.xml`, following the localization conventions. `aidenBotElapsedLabel` and `aidenBotDurationLabel` are deliberately English helpers (JVM-tested presentation helpers, like `AidenTurnElapsed`). ## 13. Not in scope @@ -364,6 +392,7 @@ Every new test file is registered in the matching `package.json` script and `scr - Workbench shell: its own PR, with C2 stacked on it (decision 12, §4). - Tool categories: §5.1 (decision 13). - Left to the plan: whether Pi Durable can withdraw a single queued submission (decides the Remove button). +- Pi Durable withdraw on phones: no Remote withdraw route in D; the Queued bubble has no Remove on phones (follow-up). - **Slice B rulings (2026-10-14):** - Pi Durable 1.0.3 withdraws one queued submission (`Harness.abortSubmission`), so the Queued bubble has Remove. It reports `withdrawn`, `started` or `gone`; only `withdrawn` removes the bubble. @@ -410,3 +439,53 @@ Every new test file is registered in the matching `package.json` script and `scr - A file chip names only the file's basename (pre-existing). - Test debt: the `lastNonBotRoute` test depends on pristine module state and lacks edge cases (trailing slash, `/chat/extra`, a query on a Bots path, `/botsy`); dead `deferred()` lines in the Files keep-on-refresh test; the render probe never uninstalls (each suite is its own process); Task 2 review minors 3, 4 and 6 on the preference store. - The Bots onboarding tile keeps its content; only its header changed from a back chevron to the panel toggle. + +- **Slice D rulings (2026-10-14).** The plan (`docs/superpowers/plans/2026-10-14-bot-chat-activity-d-phones.md`) was written before B, C1 and C2 shipped, so a pre-flight scan against the shipped code produced these rulings. `.superpowers/sdd/2026-10-14-bot-chat-activity-d-phones/progress.md` is the full record; the numbers match it. + - **R1 Queued privacy.** The Remote projector classifies queued inputs with the desktop's fail-closed classifier (`createQueuedInputClassifier`, one implementation shared by the desktop feed and every projector; throttled retry through the caller's serial lane, on the injected clock). Routine and intro inputs never reach a phone. `liveQueuedMessages` lists only ids classified as the person's own (an inclusion filter), and a classification failure is reported once per failing streak. + - **R2 Resume.** `botTurnIntents.resuming` is announced from the runtime's `onResumed` path inside the per-Bot serialize, not before `runtime.resume`, which preserves B's stale-Resume fix. Remote `control()` and the handlers never announce Resume. + - **R3 Failed Stop.** A third intent, `stopCancelled`, clears "Stopping" when a Stop fails. Both `bots:stop` and Remote Stop go through `stopWithIntents`. + - **R4 Run scoping.** `liveRunKey` is exported; the Remote projector resets `startedAt`, Stop and Resume when the run key changes, as the desktop does, so a queued follow-up never carries a stale "Stopping". `startedAt` itself comes from one process-wide record per run (`botRunStarts`, final wave FR-I1): the first surface to see a run records its start, and the desktop feed and every Remote projector read it, so a phone that opens mid-run or comes back after the projector's 60 s idle close sees the run's real elapsed time. A surface that sees the Bot not running ends the record, so the next run, or a Resume of the same one, starts afresh. + - **R5, R6.** Android defines `AidenStrictLongSerializer` beside the Int one; the delegated test transport takes the new `send` signature. + - **R7 Line visibility.** Phones use the desktop rule (hide only while reply text streams with no active tool step). + - **R8 Android Home.** `AidenBotsHomeScreen` opens Advanced with Chat activity too. + - **R9, R10, R12, R13.** Tasks 2 and 11 were split (2a/2b, 11a/11b); the desktop intents wiring is extracted and tested like `wireBotAttention`; preview tests assert per-level behaviour, never exact recap strings; execution was serial. + - **R11 Photo-only queued messages** are omitted from the queued list (no text to show). + - **R14, R18 Optimistic actions** (user directive: "all client actions immediate and optimistically loading with skeletons, not waiting to connect"). See §12. + - **R15 Scope.** The directive also covers the slice A phone surfaces (create Bot, model pick, Advanced, profile and memory edits); they are final-wave work, below. Non-Bot phone surfaces get their own plan. + - **R16 `submissionId`** on the person's own user entries, adopted into revision 30 (§11). + - **R17 Outbox**, persisted, TTL'd, file-protected, excluded from backup and purged on unpair and access loss (§12). + - **R19 Still badges** (amends R8): under Reduce Motion or Low Power a working canonical face keeps the glance mark's still pose as a badge. + - **R20.** Android ran in parallel with the iOS fix rounds, committing by pathspec in one command. + - **R21 Offline capabilities** (final wave): cache the last known server capabilities (the `bot-activity-v1` feature and the `bot:activity` grant) per installation, so the line, Steer/Queue and `whenBusy` work before `serverInfo` arrives and the Chat activity row stays reachable offline; a send frozen under the cached capability replays exactly. iOS shipped it in the final wave: `AidenInstallation.botActivityAnnounced` is persisted (absent in older records reads as false), and `aidenBotActivitySupported(server:installation:)` uses `/server` once it answers and the cache before that, for the chat and for Advanced's Chat activity row. Android ships the cached-grant path and persists the feature token in the same wave. + - Android confirmed: the pairing persists the `/server` feature tokens; a cached "supported" needs `bot-activity-v1` plus the stored grant and stands in until `/server` answers. + - **R22 Refused sends never stick and never duplicate.** (a) Phones: a definite refusal mints a new key on Try again; the "may have arrived" mark stays for echo matching only. (b) Mac: a send, Resume, Dismiss or Stop that the session refuses from state before doing anything (`BotSessionError` `needs_model`, `model_error`, `unavailable` or `bot_paused`) leaves no record in the Remote operation ledger (`AidenOperationNotExecutedError`), so a retry with the same key is evaluated afresh once the Bot is fixed, instead of replaying `internal_error` for a day. Every other failure keeps its record and replays, so nothing runs twice. Accepted residual: a Mac downgrade that refuses before the ledger a body it accepted earlier could duplicate. + - **Task 3 fold-ins.** The `bot:activity` opt-in list widens `isBotCardCapability`; the router has its own `bot:activity` branch gated on both service flags (never the progress fallthrough); `chatActivity` is stripped at one choke point for every detail-returning route (a review caught avatar DELETE leaking it; avatar PUT returns asset metadata, not a detail); `session` and `openEvents` activity are gated on the service being fully wired. + - **B Resume bug (fixed in D).** After a crash with partial text, stale `pi.live` text read as writing and cleared "Picking up where it left off". The shared derivation now takes `resumedFrom` (the `pi.live` doc as it was when the Resume began): partial text that predates the Resume does not count as writing, and Resuming holds until the first live event after it. This changes the desktop line as well as phones. + - **Review-driven fixes.** Echo claims are per epoch; the pending outbox survives dismiss, Bot switch and relaunch; TTL expiry and retry exhaustion become "Couldn't send · Try again" instead of dropping; an echo naming a foreign `submissionId` never settles a message; Android freezes the request exactly and re-gates `whenBusy` only in the controller; the Chat activity page recovers from `revision_conflict`, pauses previews off-screen and when inactive, and holds a pick up to 8 s while connecting. + - **Deviations from the plan.** `projectBotSessionQueued(docs, personal)` takes the personal id set instead of filtering `docs` alone; `BotLiveProjector` takes one optional `ProjectorActivity` dep instead of `now` and `activityEnabled`; `stopCancelled` is a third intent; the iOS and Android Chat activity models are the final (R14) models, not the plan's first draft; Android's dialog takes a host (`botChatActivityHost`) because the scope, pairing pin and cache report must outlive the dialog. + - **Final fix wave, desktop and protocol (done).** + - FR-I1 and FR-m6: the process-wide run start (R4 above), and the projector's classification pause runs on the injected clock, not `Date.now`. + - R22(b) (above). + - One shared fail-closed classifier and the inclusion filter (R1 above). The Resume baseline (`resumedFrom`) is serialized once when the Resume is heard, not on every derivation. + - A failing `placed()` lookup (a new message's `submissionId`) is reported once per failing streak, like classification. + - Tests added: a reconnect snapshot mid-run carries the phase and the queued list; `projectBotSessionActivity` keeps a 40-code-point app name only for the `app` phase; a frame that leaves the phase unchanged sends no activity frame; a held `bot:activity` grant gets activity on session and stream only while both halves are wired; the bubble-tag memo holds exactly the session window; the Stop-with-nothing-running check no longer waits a fixed 50 ms. + - Fixture: `botActivityPrivacy.rejectedNullLiterals` holds an optional key sent as the literal null (`app: null`, `queued: null` on a session and on a `queued` frame, `label: null` with `submissionId`). Every decoder must refuse each; TypeScript does. + - **Final fix wave, iOS (done; b0d8cfc4c, 04b623430; `final-fix-report-ios.md`).** + - FR-m1: the status-line clock ticks only while `activitySupported` and running or awaiting a turn. FR-m3: iOS already asked for `bot:activity` only when the server offers it (the shared rule). FR-m4: "Couldn’t send" uses the curly apostrophe; the other shared strings already matched Android. FR-m7: Chat activity requests race through `aidenBotWithin`; `AidenBotOutboxStore.load(_:)` is a test-only seam. + - FR-m5 snapshot grace: a sent message that a snapshot shows as neither queued nor echoed is marked unheld and dropped only at a later reconcile at least 10 s after the mark; seen queued again, the mark clears; a frame that is not a snapshot never marks. It applies on every `bot:activity` Mac, because the phone can't see the host failing closed. Android is to match it. + - R19: under Reduce Motion or Low Power a working canonical face wears the still glance badge, like a photo; the glance timeline is capped at 1/30 s. R21 (above). + - R15: New Bot, the model pick, Advanced Save, and Profile and Memory edits apply at once without waiting to connect, show skeletons instead of `ProgressView`, roll back with an error on failure, settle a lost answer by re-reading, and bound each request to 12 s. Bot create keeps its idempotency (the same key for the same request, a Bot already made is reused, a definite refusal takes a new key). The screens behind an Advanced Save change when the Mac confirms (no local detail can be built), and its failure shows there. + - Chat polish: 44 pt Try again and remove targets; the Steer/Queue menu with an empty draft; Queued/Sending text through a semantic token at 4.5:1 or more in every theme; the announcer resets when the line hides; a `start()` failure shows a still skeleton, copy and Try again; no shimmer while unreachable; `@ScaledMetric` and a stacked composer at accessibility sizes; follow-scroll only at the end or after the person's own send; the dead `.id("status")` is gone; a pure busy-mode test. + - Chat activity: running labels in the Mac's phase words; a refresh after a timeout shows a save that landed as saved; a pick confirmed after Advanced closed still reaches the screens behind; distinct `generic`, `offline` and `conflict` failure copy; the off-screen pause uses `onAppear`/`onDisappear` inside a `Form`; bounded test polls. + - Fixture: every null-literal sample is refused by the iOS decoders (no change needed). + - Pre-merge bug sweep (PR #479 review and follow-ups), done: Remote — `model_error` is a 409 state refusal (bbd13274e); snapshot and every frame fit the 1 MiB SSE limit by priority allocation (f78443bae, 7aebccfe5). iOS — Chat activity picks stay with their pairing (25574c1ae); a send before the outbox restore never replaces the last launch's file (cc7ae5c6b); Try Again replays the frozen Create through a catalog change (9a2feca96); outbox diagnostics through the typed wrapper (65e00968b); a restore landing after an access-loss purge is dropped (7744dd31b); a New Bot cut off by a relaunch comes back with Try Again (9685204a9, 89b91a071); Files sheet skeletons (7eddc5be8); bounded test waits (d4d63580d); Chat activity reports after leaving and keeps a pick made during a timed-out save (4030c06a5, 978caa670). Android — a restore after an access-loss purge is dropped (e0202df7a); unconfirmed edits never reach the Bot cache's disk (6352bfbad); null literals refused (ea29ffe2f); optimistic New Bot with a Creating… row and same-key Try again, surviving process death (c47d018f6, 212f69254, e348b0131); scheme-derived error text at ≥4.5:1 (4de4bf33f). Test harness — the core-git EPIPE race (3d3cbc959). + - Still open on both phones: a manual VoiceOver/TalkBack pass and an end-to-end run against a real Mac. + - Android, done (e2f678024, 7533f2096, 237e9e861; 892 JVM tests, 12/12 Bot instrumented tests on a headless AVD): FR-I2 unreadable outbox renamed aside with "Some unsent messages couldn’t be restored."; FR-I3 every Bot session request bounded to 12 s (timeout is ambiguous, key kept); FR-m2 Mac answers applied by write counters; FR-m3 asks for `bot:activity` only when `serverCapabilities` offers it; FR-m4 shared copy matches iOS and lives in `strings.xml`; FR-m5 unheld grace as on iOS; FR-m8 elapsed exposed to TalkBack (the word is announced, the per-second state is not); R21 completion (persisted `bot-activity-v1` token plus grant stand in until `/server` answers); R15 Profile, Memory, model pick and Advanced Save apply at once with rollback and skeletons; Chat activity pin by (instanceId, deviceId, createdAt). Open on Android: the fixture null-literal raw-null guard; Bot create still waits on the form ("Creating…", bounded, key replayed after a timeout) instead of an optimistic Home row; inline error text (`palette.danger`) is about 3:1 in some light themes. + - **Parked and final-wave items (open when this was written).** Follow-ups unless the pre-merge sweep above closed them: + - Desktop: overlapping Stops where one fails briefly clear "Stopping" (cosmetic). + - Fixtures: a whitespace-only `app` label parity note (the Android raw-null guard shipped in ea29ffe2f). + - Android chat polish: follow-scroll only at or near the bottom or after the person's own send (check iOS too); Try again contrast of at least 4.5:1 through a scheme token, and the contrast test extended to captions and links; a failed bubble `stateDescription`; `AnimatedVisibility` for the hint row and in-flight Try again feedback; one shared power-save receiver per screen with the glance and skeleton capped near 30 fps; first-read 401/403 shows access-lost copy, not "Mac nearby"; move " ·" into `strings.xml`; sent bubble colours through `aidenBotUserBubbleColors`; a narrower `botDetails` collection; drop the duplicate Bot GET; the split button at large font; test name accuracy. + - Android controller: read outside the store lock and merge under it; the restore continuation resumes on a throw; caps re-checked after the merge (refuse or keep failed, never let `load` drop the newest); `deliver` re-checks released after a refused freeze; release claims and `keepsKey=frozen` on FAILED-for-state paths; Resume clears only on non-null activity; inline io in the four store tests. + - Final review findings for Android: set an unreadable outbox aside with a notice instead of dropping it (FR-I2); bound every Bot request to 12 s (FR-I3); request `bot:activity` only when the server's capability list offers it (FR-m3); share copy with iOS (FR-m4); the iOS snapshot grace period (FR-m5); a write counter for answers (FR-m2); elapsed time as TalkBack state (FR-m8); R15 and R21 completion. + - A manual VoiceOver and TalkBack pass; the manual end-to-end checks in the plan's Task 12 Step 4 were not run in this environment (no paired Mac build). + - No Remove for a queued message on phones, no durable queue for Chat activity picks (both by design). diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift index 6a6da4e77..7e8263d49 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift @@ -1,12 +1,82 @@ import SwiftUI -private struct AidenBotAdvancedSaveAttempt: Equatable { - let context: AidenRemoteRequestContext - let botID: String - let identityRevision: String - let accessRevision: String - let catalogRevision: String - let token: UUID +/// Copy for an Advanced Save that didn't stick, shown by the screen behind it. +enum AidenBotAdvancedCopy { + static let unreachable = "Aiden couldn’t reach your Mac, so Advanced wasn’t saved. Open it to try again." + static let changedOnMac = "Something changed on your Mac, so Advanced wasn’t saved. Open it to check and save again." + static let couldNotCheck = "Aiden couldn’t check what Advanced saved. Open it again to see." +} + +/// The requests one Advanced Save makes; the remote client provides them, tests a fake. +struct AidenBotAdvancedSaveOperations { + var waitForConnection: @MainActor (Duration) async -> Bool + var updateIdentity: @MainActor (_ revision: String, AidenBotIdentityPatch) async throws -> AidenBotDetail + var updateAccess: @MainActor (_ revision: String, AidenBotAccessUpdate) async throws -> Void + var readBot: @MainActor () async throws -> AidenBotDetail + var readCatalog: @MainActor () async throws -> AidenBotCapabilityCatalog + /// True when the error revoked this phone; the save then stops quietly. + var handleRevocation: @MainActor (Error) async -> Bool = { _ in false } +} + +enum AidenBotAdvancedSaveOutcome: Equatable { + case saved(AidenBotDetail) + case failed(String) + /// This phone lost access; nothing more to say. + case stopped +} + +/// One Advanced Save, which runs after the page has closed (R15): it waits +/// briefly for the Mac, sends the identity and access changes on the revisions +/// the page was edited on, and re-reads the Bot. A lost answer is settled by +/// re-reading, so a change the Mac saved is never sent twice or called failed. +@MainActor +func aidenBotAdvancedSave( + draft: AidenBotEditorDraft, + baseline: AidenBotDetail, + catalog: AidenBotCapabilityCatalog, + operations: AidenBotAdvancedSaveOperations, + connectionHold: Duration = .seconds(8), + requestLimit: Duration = .seconds(12) +) async -> AidenBotAdvancedSaveOutcome { + func bounded(_ operation: @escaping @MainActor () async throws -> T) async throws -> T { + let call = AidenBotMainActorCall(operation) + return try await aidenBotWithin(requestLimit) { try await call.call() } + } + do { + guard await operations.waitForConnection(connectionHold) else { return .failed(AidenBotAdvancedCopy.unreachable) } + var latest = baseline + if let patch = try draft.identityPatch(comparedTo: baseline) { + latest = try await bounded { try await operations.updateIdentity(baseline.revision, patch) } + } + if try draft.changesAccess(comparedTo: baseline, catalog: catalog) { + let update = try draft.accessUpdate(catalog: catalog) + try await bounded { try await operations.updateAccess(baseline.access.revision, update) } + latest = try await bounded { try await operations.readBot() } + } + return .saved(latest) + } catch is CancellationError { + return .stopped + } catch { + if await operations.handleRevocation(error) { return .stopped } + do { + let authoritative = try await bounded { try await operations.readBot() } + let refreshedCatalog = try await bounded { try await operations.readCatalog() } + let rebased = try aidenBotEditorRebasedDraft( + draft, + baseline: baseline, + baselineCatalog: catalog, + authoritative: authoritative, + authoritativeCatalog: refreshedCatalog + ) + if (try? rebased.isSatisfied(by: authoritative, catalog: refreshedCatalog)) == true { + return .saved(authoritative) + } + return .failed(AidenBotAdvancedCopy.changedOnMac) + } catch { + if await operations.handleRevocation(error) { return .stopped } + return .failed(AidenBotAdvancedCopy.couldNotCheck) + } + } } /// One page for the settings most people never need: the AI model, the image @@ -16,6 +86,8 @@ struct AidenBotAdvancedView: View { @Bindable var coordinator: AidenRemoteCoordinator let botID: String var onSaved: (AidenBotDetail) -> Void = { _ in } + /// A Save that didn't reach the Mac, after Advanced closed: the screen behind says so. + var onSaveFailed: (String) -> Void = { _ in } @Environment(\.dismiss) private var dismiss @Environment(\.aidenPalette) private var palette @@ -26,16 +98,16 @@ struct AidenBotAdvancedView: View { @State private var capturedContext: AidenRemoteRequestContext? @State private var isLoading = true @State private var loadError: String? - @State private var saveError: String? - @State private var activeAttempt: AidenBotAdvancedSaveAttempt? @State private var isConfirmingDiscard = false + @State private var chatActivityModel: AidenBotChatActivityModel? + @State private var isShowingChatActivity = false + /// Chat activity saves, reported to the screens behind Advanced once, even after it closes. + @State private var chatActivityReport = AidenBotChatActivityReport() private var sessionIdentity: AidenBotCustomAccessSessionIdentity { AidenBotCustomAccessSessionIdentity(coordinator: coordinator) } - private var isSaving: Bool { activeAttempt != nil } - private var isDirty: Bool { aidenBotEditorIsDirty( draft: draft, @@ -46,15 +118,15 @@ struct AidenBotAdvancedView: View { ) } + /// Editing works on the saved details at once, connected or not (R15); Save + /// waits briefly for the Mac after the page closes. private var canWrite: Bool { capturedContext.map(coordinator.isCurrent) == true - && coordinator.connectionState == .connected && coordinator.installationStore.activeInstallation?.canWriteBots == true } private var canSave: Bool { - guard canWrite, !isLoading, !isSaving, isDirty, - let catalog, let draft, draft.isSaveable(catalog: catalog) else { return false } + guard canWrite, isDirty, let catalog, let draft, draft.isSaveable(catalog: catalog) else { return false } return true } @@ -62,36 +134,38 @@ struct AidenBotAdvancedView: View { content .navigationTitle("Advanced") .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(isDirty || isSaving) + .navigationBarBackButtonHidden(isDirty) .toolbar { - if isDirty || isSaving { + if isDirty { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { isConfirmingDiscard = true } - .disabled(isSaving) } ToolbarItem(placement: .confirmationAction) { - Button(isSaving ? "Saving…" : "Save") { - Task { await save() } - } - .disabled(!canSave) + Button("Save") { save() } + .disabled(!canSave) } } } - .task(id: sessionIdentity) { + // A new pairing starts over; a connection coming up only re-reads, keeping edits. + .task(id: sessionIdentity.pairing) { let expectedSession = sessionIdentity reset(for: expectedSession) await load(for: expectedSession) } - .alert( - "Couldn’t Save", - isPresented: Binding( - get: { saveError != nil }, - set: { if !$0 { saveError = nil } } + .onChange(of: coordinator.connectionState == .connected) { _, connected in + guard connected, baselineBot != nil else { return } + let expectedSession = sessionIdentity + Task { await load(for: expectedSession) } + } + .onAppear { chatActivityReport.appeared(.advanced) } + .onDisappear { + // Closing Advanced (not opening Chat activity over it) reports a saved pick once; + // one that lands later is reported when it lands. + chatActivityReport.disappeared( + .advanced, + replacedBy: isShowingChatActivity ? .chatActivity : nil, + reportBehind: onSaved ) - ) { - Button("OK", role: .cancel) { saveError = nil } - } message: { - Text(saveError ?? "Your changes weren’t saved.") } .confirmationDialog( "Discard changes?", @@ -134,6 +208,9 @@ struct AidenBotAdvancedView: View { } } + /// Only a phone that may not change Bots is locked; Chat activity has its own rules. + private var isLocked: Bool { !canWrite } + private func form(_ catalog: AidenBotCapabilityCatalog) -> some View { Form { if !canWrite { @@ -141,20 +218,42 @@ struct AidenBotAdvancedView: View { Label(readOnlyMessage, systemImage: "lock.fill") .foregroundStyle(palette.secondary) } + } else if coordinator.connectionState != .connected { + Section { + // Never a lock: changes save once the Mac is reachable. + Text(AidenBotSessionCopy.connecting) + .font(.footnote) + .foregroundStyle(palette.secondary) + } } - modelSection(catalog) - accessSection(catalog) - if draft?.usesFullAccess == false { - optionSection(title: "Connections", options: catalog.connections, keyPath: \.connectionIDs) - optionSection(title: "Skills", options: catalog.skills, keyPath: \.skillIDs) - filesAndCommandsSection(catalog) - optionSection(title: "Other", options: catalog.otherCapabilities, keyPath: \.otherCapabilityIDs) + modelSection(catalog).disabled(isLocked) + accessSection(catalog).disabled(isLocked) + chatActivitySection + Group { + if draft?.usesFullAccess == false { + optionSection(title: "Connections", options: catalog.connections, keyPath: \.connectionIDs) + optionSection(title: "Skills", options: catalog.skills, keyPath: \.skillIDs) + filesAndCommandsSection(catalog) + optionSection(title: "Other", options: catalog.otherCapabilities, keyPath: \.otherCapabilityIDs) + } } + .disabled(isLocked) } .scrollContentBackground(.hidden) .background(palette.canvas) - .disabled(isSaving || !canWrite) .scrollDismissesKeyboard(.interactively) + .navigationDestination(isPresented: $isShowingChatActivity) { + if let chatActivityModel, let baselineBot { + AidenBotChatActivityView( + bot: baselineBot, + canWrite: coordinator.installationStore.activeInstallation?.canWriteBots == true, + model: chatActivityModel + ) + .onAppear { chatActivityReport.appeared(.chatActivity) } + // The sheet or the chat can close straight from here, with Advanced never showing again. + .onDisappear { chatActivityReport.disappeared(.chatActivity, reportBehind: onSaved) } + } + } } private func modelSection(_ catalog: AidenBotCapabilityCatalog) -> some View { @@ -212,6 +311,90 @@ struct AidenBotAdvancedView: View { } } + /// Revision 30: hidden unless the Mac announces `bot-activity-v1` and this phone holds + /// `bot:activity`. It opens from the cached Bot, so it stays reachable offline. + @ViewBuilder + private var chatActivitySection: some View { + if aidenBotShowsChatActivity( + server: coordinator.server, + installation: coordinator.installationStore.activeInstallation, + hasBaseline: baselineBot != nil + ), let baselineBot { + Section { + Button { + openChatActivity(baselineBot) + } label: { + HStack { + LabeledContent(AidenBotChatActivityCopy.title, value: AidenBotChatActivityCopy.summary(baselineBot.chatActivity)) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(palette.secondary.opacity(0.6)) + .accessibilityHidden(true) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isDirty) + } footer: { + Text(isDirty ? "Save or cancel your changes first." : "What you see in its chat on your Mac while it works.") + } + } + } + + private func openChatActivity(_ bot: AidenBotDetail) { + // A model still saving keeps its own revision; reopening it must not fork a second writer. + if chatActivityModel?.isSaving != true { + let botID = bot.id + // The pairing this page opens under; every request and result stays with it. + let context = try? coordinator.requestContext() + let isCurrent: @MainActor () -> Bool = { [coordinator] in + context.map(coordinator.isCurrent) == true + } + chatActivityModel = AidenBotChatActivityModel( + bot: bot, + save: { next, revision in + guard let context else { throw AidenRemoteClientError.installationChanged } + let client = try coordinator.remoteClient(for: context) + return try await client.updateBotIdentity( + id: botID, + revision: revision, + patch: try AidenBotIdentityPatch(chatActivity: next) + ) + }, + refresh: { + guard let context else { throw AidenRemoteClientError.installationChanged } + let client = try coordinator.remoteClient(for: context) + return try await client.bot(id: botID) + }, + onSaved: { [report = chatActivityReport, onSaved] saved in + // Another pairing's Bot is never shown, cached or reported here. + guard let context, isCurrent() else { return } + // Advanced's own Save, and its summary row, move to the newest revision. + // The screens behind hear about it once Advanced closes, or at once after. + baselineBot = saved + cacheDetail(saved, for: context) + report.saved(saved, reportBehind: onSaved) + }, + waitForConnection: { hold in await aidenBotWaitForConnection(coordinator, hold: hold) }, + isCurrent: isCurrent + ) + } + isShowingChatActivity = true + } + + private func cacheDetail(_ detail: AidenBotDetail, for context: AidenRemoteRequestContext) { + guard coordinator.isCurrent(context) else { return } + Task { + _ = await coordinator.withRetainedInstallationData(for: context) { + _ = try? await AidenBotCache.shared.upsertDetailAndStore( + detail, + instanceId: context.instanceId, + deviceId: context.deviceId + ) + } + } + } + private func filesAndCommandsSection(_ catalog: AidenBotCapabilityCatalog) -> some View { Section { ForEach(aidenBotVisibleCapabilityOptionsForFiles(catalog)) { option in @@ -325,7 +508,7 @@ struct AidenBotAdvancedView: View { } private var readOnlyMessage: String { - if coordinator.connectionState != .connected { return "Connect to your Mac to change this." } + if capturedContext == nil { return "Connect to your Mac to change this." } return "This phone can look but not change Bots." } @@ -340,44 +523,59 @@ struct AidenBotAdvancedView: View { capturedContext = nil isLoading = true loadError = nil - saveError = nil - activeAttempt = nil isConfirmingDiscard = false + chatActivityModel = nil + isShowingChatActivity = false + chatActivityReport = AidenBotChatActivityReport() } @MainActor private func load(for expectedSession: AidenBotCustomAccessSessionIdentity) async { - guard sessionIdentity == expectedSession else { return } - isLoading = true + guard sessionIdentity.pairing == expectedSession.pairing else { return } + if draft == nil { isLoading = true } loadError = nil var requestContext: AidenRemoteRequestContext? do { let context = try coordinator.requestContext() requestContext = context - guard coordinator.isCurrent(context), sessionIdentity == expectedSession else { return } - if let cached = await AidenBotCache.shared.load( + guard coordinator.isCurrent(context), sessionIdentity.pairing == expectedSession.pairing else { return } + if draft == nil, let cached = await AidenBotCache.shared.load( instanceId: context.instanceId, deviceId: context.deviceId ), let cachedCatalog = cached.catalog(forBotID: botID), let cachedBot = cached.details.first(where: { $0.id == botID }), let cachedDraft = AidenBotEditorDraft(detail: cachedBot, catalog: cachedCatalog) { - guard coordinator.isCurrent(context), sessionIdentity == expectedSession else { return } + guard coordinator.isCurrent(context), sessionIdentity.pairing == expectedSession.pairing else { return } catalog = cachedCatalog baselineBot = cachedBot draft = cachedDraft + // The saved settings are editable at once; Save waits for the Mac (R15). + capturedContext = context isLoading = false } let client = try coordinator.remoteClient(for: context) async let catalogRequest = client.botCapabilityCatalog(botId: botID) async let detailRequest = client.bot(id: botID) let (loadedCatalog, loadedBot) = try await (catalogRequest, detailRequest) - guard coordinator.isCurrent(context), sessionIdentity == expectedSession, + guard coordinator.isCurrent(context), sessionIdentity.pairing == expectedSession.pairing, !Task.isCancelled else { return } - let loadedDraft = try aidenBotEditorResolvedDraft( - mode: .edit(botID: botID), - catalog: loadedCatalog, - bot: loadedBot - ) + let loadedDraft: AidenBotEditorDraft + if isDirty, let draft, let baselineBot, let catalog { + // Edits made before the Mac answered stay, on top of what it holds now. + loadedDraft = try aidenBotEditorRebasedDraft( + draft, + baseline: baselineBot, + baselineCatalog: catalog, + authoritative: loadedBot, + authoritativeCatalog: loadedCatalog + ) + } else { + loadedDraft = try aidenBotEditorResolvedDraft( + mode: .edit(botID: botID), + catalog: loadedCatalog, + bot: loadedBot + ) + } capturedContext = context catalog = loadedCatalog baselineBot = loadedBot @@ -394,7 +592,7 @@ struct AidenBotAdvancedView: View { deviceId: context.deviceId ) } - guard coordinator.isCurrent(context), sessionIdentity == expectedSession, + guard coordinator.isCurrent(context), sessionIdentity.pairing == expectedSession.pairing, !Task.isCancelled else { return } isLoading = false } catch is CancellationError { @@ -402,102 +600,60 @@ struct AidenBotAdvancedView: View { } catch { if let context = requestContext, await coordinator.handleCredentialRevocation(error, context: context) { return } - guard sessionIdentity == expectedSession else { return } - capturedContext = nil - loadError = aidenBotFriendlyLoadError(error) + guard sessionIdentity.pairing == expectedSession.pairing else { return } + // Saved settings stay editable offline; with none, there is nothing to edit. + if draft == nil { + capturedContext = nil + loadError = aidenBotFriendlyLoadError(error) + } isLoading = false } } + /// Save closes Advanced at once (R15). The change goes out after, waiting + /// briefly for the Mac; the screens behind hear the Mac's Bot, or the error. @MainActor - private func save() async { + private func save() { guard canSave, let context = capturedContext, coordinator.isCurrent(context), let catalog, let draft, let baselineBot else { return } - let attempt = AidenBotAdvancedSaveAttempt( - context: context, - botID: baselineBot.id, - identityRevision: baselineBot.revision, - accessRevision: baselineBot.access.revision, - catalogRevision: catalog.revision, - token: UUID() + let botID = baselineBot.id + let coordinator = coordinator + let report = chatActivityReport + let onSaved = onSaved + let onSaveFailed = onSaveFailed + let operations = AidenBotAdvancedSaveOperations( + waitForConnection: { hold in await aidenBotWaitForConnection(coordinator, hold: hold) }, + updateIdentity: { revision, patch in + try await coordinator.remoteClient(for: context).updateBotIdentity(id: botID, revision: revision, patch: patch) + }, + updateAccess: { revision, update in + _ = try await coordinator.remoteClient(for: context).updateBotAccess(botId: botID, revision: revision, update: update) + }, + readBot: { try await coordinator.remoteClient(for: context).bot(id: botID) }, + readCatalog: { try await coordinator.remoteClient(for: context).botCapabilityCatalog(botId: botID) }, + handleRevocation: { error in await coordinator.handleCredentialRevocation(error, context: context) } ) - activeAttempt = attempt - saveError = nil - defer { if activeAttempt == attempt { activeAttempt = nil } } - do { - let client = try coordinator.remoteClient(for: attempt.context) - var latest = baselineBot - if let patch = try draft.identityPatch(comparedTo: baselineBot) { - guard isCurrent(attempt) else { return } - latest = try await client.updateBotIdentity( - id: attempt.botID, - revision: attempt.identityRevision, - patch: patch - ) - guard isCurrent(attempt) else { return } - self.baselineBot = latest - } - if try draft.changesAccess(comparedTo: baselineBot, catalog: catalog) { - guard isCurrent(attempt) else { return } - _ = try await client.updateBotAccess( - botId: attempt.botID, - revision: attempt.accessRevision, - update: try draft.accessUpdate(catalog: catalog) - ) - guard isCurrent(attempt) else { return } - latest = try await client.bot(id: attempt.botID) - guard isCurrent(attempt) else { return } - self.baselineBot = latest - } - guard isCurrent(attempt) else { return } - onSaved(latest) - dismiss() - } catch is CancellationError { - return - } catch { - if await coordinator.handleCredentialRevocation(error, context: attempt.context) { return } - guard isCurrent(attempt) else { return } - // A response can be lost after the Mac saves. Re-read the Bot - // before allowing a retry so stale revisions never repeat an edit. - do { - let client = try coordinator.remoteClient(for: attempt.context) - async let detailRequest = client.bot(id: attempt.botID) - async let catalogRequest = client.botCapabilityCatalog(botId: attempt.botID) - let (authoritative, refreshedCatalog) = try await (detailRequest, catalogRequest) - guard isCurrent(attempt) else { return } - let rebasedDraft = try aidenBotEditorRebasedDraft( - draft, - baseline: baselineBot, - baselineCatalog: catalog, - authoritative: authoritative, - authoritativeCatalog: refreshedCatalog - ) - self.baselineBot = authoritative - self.catalog = refreshedCatalog - self.draft = rebasedDraft - if (try? rebasedDraft.isSatisfied(by: authoritative, catalog: refreshedCatalog)) == true { - onSaved(authoritative) - dismiss() - } else { - saveError = "Something changed on your Mac. Check your changes and save again." + Task { @MainActor in + let outcome = await aidenBotAdvancedSave(draft: draft, baseline: baselineBot, catalog: catalog, operations: operations) + guard coordinator.isCurrent(context) else { return } + switch outcome { + case let .saved(latest): + report.reportedByAdvanced() + _ = await coordinator.withRetainedInstallationData(for: context) { + _ = try? await AidenBotCache.shared.upsertDetailAndStore( + latest, + instanceId: context.instanceId, + deviceId: context.deviceId + ) } - } catch is CancellationError { - return - } catch let reconciliationError { - if await coordinator.handleCredentialRevocation(reconciliationError, context: attempt.context) { return } - guard isCurrent(attempt) else { return } - capturedContext = nil - saveError = "Aiden couldn’t check what was saved. Go back and open Advanced again." + onSaved(latest) + case let .failed(message): + onSaveFailed(message) + case .stopped: + break } } - } - - @MainActor - private func isCurrent(_ attempt: AidenBotAdvancedSaveAttempt) -> Bool { - coordinator.isCurrent(attempt.context) - && capturedContext == attempt.context - && activeAttempt == attempt - && baselineBot?.id == attempt.botID + dismiss() } } @@ -640,3 +796,568 @@ struct AidenBotModelListView: View { .disabled(!usable) } } + +// MARK: - Chat activity (spec §7, contract revision 30) + +/// Advanced offers Chat activity on a Mac that announces `bot-activity-v1` to a phone that +/// holds `bot:activity`, once a Bot's detail (cached or fresh) is on screen. Offline, the +/// pairing's last-known capabilities decide (R21). +func aidenBotShowsChatActivity(server: AidenServer?, installation: AidenInstallation?, hasBaseline: Bool) -> Bool { + hasBaseline && aidenBotActivitySupported(server: server, installation: installation) +} + +/// The Bot changed on the Mac since this phone read it (a stale `If-Match`). +func aidenBotIsRevisionConflict(_ error: Error) -> Bool { + guard case let AidenRemoteClientError.server(status, body) = error else { return false } + return status == 409 || body.code == AidenRemoteErrorCode(rawValue: "revision_conflict") +} + +/// Waits for the Mac connection to come up, so an optimistic pick made while connecting +/// still goes out. False once `hold` runs out, or when no Mac is paired. +@MainActor +func aidenBotWaitForConnection(_ coordinator: AidenRemoteCoordinator, hold: Duration) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: hold) + while coordinator.connectionState != .connected { + if coordinator.connectionState == .needsPairing || clock.now >= deadline || Task.isCancelled { return false } + try? await Task.sleep(for: .milliseconds(200)) + } + return true +} + + +/// Copy shared with the desktop page (`bot-chat-activity-page.tsx`) and Android. +enum AidenBotChatActivityCopy { + static let title = "Chat activity" + static let levelTitle = "How much to show" + static let wordingTitle = "What the status line says" + static let phonesTitle = "On iPhone and Android" + static let phonesFooter = "Always just the line, with the same words." + static let recommended = "Recommended" + static let saveFailed = "Aiden couldn’t save that choice." + static let saveConflict = "This changed on your Mac. Showing what it has now." + static let saveUnreachable = "Aiden couldn’t reach your Mac to save that choice." + + static func message(_ failure: AidenBotChatActivityFailure) -> String { + switch failure { + case .generic: saveFailed + case .offline: saveUnreachable + case .conflict: saveConflict + } + } + + static func label(_ level: AidenBotChatActivityLevel) -> String { + switch level { + case .quiet: "Quiet" + case .summary: "Summary" + case .detailed: "Detailed" + } + } + + static func detail(_ level: AidenBotChatActivityLevel) -> String { + switch level { + case .quiet: "One line that says what it’s doing." + case .summary: "Adds a short recap above each reply." + case .detailed: "Also shows its last few steps while it works." + } + } + + static func label(_ wording: AidenBotChatActivityWording) -> String { + switch wording { + case .follows: "Follows the work" + case .thinking: "Always “Thinking”" + } + } + + static func detail(_ wording: AidenBotChatActivityWording) -> String { + switch wording { + case .follows: "Says what it’s doing, like Reading or Searching the web." + case .thinking: "One calm word, whatever it’s doing." + } + } + + /// Advanced's trailing value: "Quiet · Follows the work". + static func summary(_ value: AidenBotChatActivity?) -> String { + let resolved = value ?? .standard + return "\(label(resolved.level)) · \(label(resolved.wording))" + } + + static func description(botName: String) -> String { + "What you see in \(botName)’s chat on your Mac while it works. Changes save as you pick them." + } +} + +/// One scripted turn, as each level shows it on the Mac: think, read, search, edit, reply. +enum AidenBotChatActivityPreview { + struct Frame: Equatable { + /// The line's phase, or nil on the reply frame. + let phase: String? + /// Finished step rows: Detailed while live, or the open recap after the reply. + let steps: [String] + /// The step in progress, listed while the line says its phase (Detailed only). + let running: String? + let recap: String? + let recapOpen: Bool + } + + static let durations: [TimeInterval] = [1.4, 1.6, 1.6, 1.4, 2.8] + static let prompt = "Plan dinners for this week" + static let reply = "Here’s a plan for the week." + static let recap = "Worked for 18s · read 1 file, searched the web, edited 1 file" + + private static let phases: [String?] = ["thinking", "reading", "searching", "editing", nil] + private static let stepLabels = ["Read 1 file", "Searched the web", "Edited 1 file"] + /// The step in progress reads in the Mac's own phase words, as its line does. + private static let runningLabels = ["reading", "searching", "editing"].map { + AidenBotActivityPhase.display(phase: $0, wording: .follows, app: nil).word + } + + /// Reduce Motion's still frame. Summary's whole point is the recap above the reply, so it + /// holds the reply; Quiet and Detailed hold the line (and Detailed's steps) mid-turn. + static func stillIndex(for level: AidenBotChatActivityLevel) -> Int { + level == .summary ? phases.count - 1 : 2 + } + + static func frameIndex(elapsed: TimeInterval) -> Int { + let total = durations.reduce(0, +) + var time = elapsed.truncatingRemainder(dividingBy: total) + if time < 0 { time += total } + for (index, duration) in durations.enumerated() { + if time < duration { return index } + time -= duration + } + return durations.count - 1 + } + + static func frame(index: Int, level: AidenBotChatActivityLevel) -> Frame { + let clamped = min(max(index, 0), phases.count - 1) + if let phase = phases[clamped] { + let live = level == .detailed && clamped >= 1 + return Frame( + phase: phase, + steps: live ? Array(stepLabels.prefix(clamped - 1)) : [], + running: live ? runningLabels[clamped - 1] : nil, + recap: nil, + recapOpen: false + ) + } + switch level { + case .quiet: return Frame(phase: nil, steps: [], running: nil, recap: nil, recapOpen: false) + case .summary: return Frame(phase: nil, steps: [], running: nil, recap: recap, recapOpen: false) + case .detailed: return Frame(phase: nil, steps: stepLabels, running: nil, recap: recap, recapOpen: true) + } + } +} + +/// The Mac stayed unreachable for the whole hold. +struct AidenBotChatActivityUnreachable: Error {} + +/// Carries a main-actor closure into `aidenBotWithin`'s race; it is only ever called on the main actor. +struct AidenBotMainActorCall: @unchecked Sendable { + let call: Function + init(_ call: Function) { self.call = call } +} + +/// Why a pick didn't stick, so the page says so in the right words (Android parity). +enum AidenBotChatActivityFailure: Equatable { + /// The Mac refused or failed the save. + case generic + /// The phone couldn't reach the Mac: offline, disconnected, or too slow. + case offline + /// The Bot changed on the Mac; the page now shows what the Mac holds. + case conflict +} + +/// The save may or may not have reached the Mac: no answer came, so a late success is possible. +func aidenBotChatActivityTransportFailure(_ error: Error) -> Bool { + error is AidenBotRequestTimedOut || error is URLError +} + +/// Reports Chat activity saves to the screens behind Advanced. While Advanced or +/// its Chat activity page shows, it holds the newest, to report once when the +/// last of them closes, whichever page that is (the sheet can close from Chat +/// activity); a save that lands after that is reported at once, so it is never lost. +@MainActor +final class AidenBotChatActivityReport { + enum Page: Hashable { + case advanced, chatActivity + } + + private var showing: Set = [.advanced] + private var unreported: AidenBotDetail? + + func saved(_ detail: AidenBotDetail, reportBehind: (AidenBotDetail) -> Void) { + if showing.isEmpty { + reportBehind(detail) + } else { + unreported = detail + } + } + + func appeared(_ page: Page) { showing.insert(page) } + + /// `page` left the screen; `replacedBy` is the page pushed over it. Once neither + /// page shows, what was held is reported once, and later saves go straight behind. + func disappeared(_ page: Page, replacedBy next: Page? = nil, reportBehind: (AidenBotDetail) -> Void) { + showing.remove(page) + if let next { showing.insert(next) } + guard showing.isEmpty, let detail = unreported else { return } + unreported = nil + reportBehind(detail) + } + + /// Advanced's own Save reported the newest Bot; nothing older is held. + func reportedByAdvanced() { unreported = nil } +} + +/// Applies each pick at once and saves it with no Save button, like the Mac page. +/// A failed save returns to the last choice the Mac confirmed. A pick made while a +/// save is in flight shows at once and goes out next (the last pick wins), on the +/// revision that save returned. A stale revision refreshes the Bot and adopts the Mac's choice. +/// Nothing is queued durably: a pick is held only for `connectionHold` while the Mac connects. +@MainActor +@Observable +final class AidenBotChatActivityModel { + /// What the page shows: the last pick, saved or not. + private(set) var value: AidenBotChatActivity + private(set) var isSaving = false + /// Why the last pick didn't stick, until the next pick. + private(set) var saveFailure: AidenBotChatActivityFailure? + var errorMessage: String? + + @ObservationIgnored private var saved: AidenBotChatActivity + @ObservationIgnored private var pending: AidenBotChatActivity? + @ObservationIgnored private var revision: String + @ObservationIgnored private let save: @MainActor (AidenBotChatActivity, String) async throws -> AidenBotDetail + @ObservationIgnored private let refresh: @MainActor () async throws -> AidenBotDetail + @ObservationIgnored private let onSaved: @MainActor (AidenBotDetail) -> Void + @ObservationIgnored private let waitForConnection: (@MainActor (Duration) async -> Bool)? + /// False once the pairing this page opened under is no longer the active one. + @ObservationIgnored private let isCurrent: @MainActor () -> Bool + @ObservationIgnored private let connectionHold: Duration + @ObservationIgnored private let requestLimit: Duration + + /// `requestLimit` bounds each request: the phone's URLSession waits for connectivity + /// for minutes, which a settings pick must not. `isCurrent` fences the pairing: once it + /// is false nothing more is sent, and a result that comes back is dropped unreported. + init( + bot: AidenBotDetail, + save: @escaping @MainActor (AidenBotChatActivity, String) async throws -> AidenBotDetail, + refresh: @escaping @MainActor () async throws -> AidenBotDetail, + onSaved: @escaping @MainActor (AidenBotDetail) -> Void, + waitForConnection: (@MainActor (Duration) async -> Bool)? = nil, + isCurrent: @escaping @MainActor () -> Bool = { true }, + connectionHold: Duration = .seconds(8), + requestLimit: Duration = .seconds(12) + ) { + let current = bot.chatActivity ?? .standard + value = current + saved = current + revision = bot.revision + self.save = save + self.refresh = refresh + self.onSaved = onSaved + self.waitForConnection = waitForConnection + self.isCurrent = isCurrent + self.connectionHold = connectionHold + self.requestLimit = requestLimit + } + + func pick(_ next: AidenBotChatActivity) async { + guard next != value else { return } + value = next + errorMessage = nil + saveFailure = nil + if isSaving { + pending = next + return + } + isSaving = true + defer { isSaving = false } + var target = next + while true { + guard await send(target) else { return } + guard let following = pending else { break } + pending = nil + if following == saved { break } + target = following + } + value = saved + } + + /// One save: true when the Mac holds `target` afterwards. A stale revision, a + /// timeout or a transport failure re-reads the Bot first, so a save that landed + /// late shows as saved with no error, and anything else returns to what the Mac holds. + private func send(_ target: AidenBotChatActivity) async -> Bool { + do { + guard isCurrent() else { return giveUp() } + if let waitForConnection { + let connected = await waitForConnection(connectionHold) + // Another pairing came up during the hold: the pick was for the Mac that left. + guard isCurrent() else { return giveUp() } + if !connected { throw AidenBotChatActivityUnreachable() } + } + // The closures run on the main actor; the box only carries them across the race. + let save = AidenBotMainActorCall(save) + let sentRevision = revision + let detail = try await aidenBotWithin(requestLimit) { try await save.call(target, sentRevision) } + guard isCurrent() else { return giveUp() } + revision = detail.revision + saved = detail.chatActivity ?? target + onSaved(detail) + return true + } catch { + guard isCurrent() else { return giveUp() } + if error is CancellationError { + pending = nil + value = saved + return false + } + let conflict = aidenBotIsRevisionConflict(error) + if conflict || aidenBotChatActivityTransportFailure(error), + let fresh = await adoptTheMacsChoice(), + (fresh.chatActivity ?? .standard) == target { + // The Mac holds this pick after all: one made meanwhile goes out next, on the fresh revision. + return true + } + // The pairing changed during the refresh: nothing more is said about that Mac's Bot. + guard isCurrent() else { return giveUp() } + pending = nil + let failure: AidenBotChatActivityFailure + if conflict { + failure = .conflict + } else if aidenBotChatActivityTransportFailure(error) || error is AidenBotChatActivityUnreachable { + failure = .offline + } else { + failure = .generic + } + value = saved + saveFailure = failure + errorMessage = AidenBotChatActivityCopy.message(failure) + return false + } + } + + /// The pairing changed under the pick: it goes back to what this page last saw, with no error, + /// since the person is no longer looking at that Mac's Bot. + private func giveUp() -> Bool { + pending = nil + value = saved + return false + } + + /// The Mac may hold something else now: take its revision and choice, so the next pick can save. + private func adoptTheMacsChoice() async -> AidenBotDetail? { + guard isCurrent() else { return nil } + let refresh = AidenBotMainActorCall(refresh) + guard let fresh = try? await aidenBotWithin(requestLimit, { try await refresh.call() }), + isCurrent() else { return nil } + revision = fresh.revision + saved = fresh.chatActivity ?? .standard + onSaved(fresh) + return fresh + } +} + +struct AidenBotChatActivityView: View { + let botName: String + let canWrite: Bool + @State private var model: AidenBotChatActivityModel + @Environment(\.aidenPalette) private var palette + + init(bot: AidenBotDetail, canWrite: Bool, model: AidenBotChatActivityModel) { + botName = bot.name + self.canWrite = canWrite + _model = State(initialValue: model) + } + + var body: some View { + Form { + if !canWrite { + Section { + Label("This phone can look but not change Bots.", systemImage: "lock.fill") + .foregroundStyle(palette.secondary) + } + } + Section { + Text(AidenBotChatActivityCopy.description(botName: botName)) + .font(.footnote) + .foregroundStyle(palette.secondary) + .listRowBackground(palette.raised) + } + Section(AidenBotChatActivityCopy.levelTitle) { + let levels = AidenBotChatActivityLevel.allCases + ForEach(Array(levels.enumerated()), id: \.element) { index, level in + choiceRow( + title: AidenBotChatActivityCopy.label(level), + detail: AidenBotChatActivityCopy.detail(level), + recommended: level == .quiet, + selected: model.value.level == level, + neighbors: neighbors(levels.map { model.value.level == $0 }, index), + preview: AidenBotChatActivityPreviewView(level: level, wording: model.value.wording) + ) { Task { await model.pick(AidenBotChatActivity(level: level, wording: model.value.wording)) } } + } + } + Section(AidenBotChatActivityCopy.wordingTitle) { + let wordings = AidenBotChatActivityWording.allCases + ForEach(Array(wordings.enumerated()), id: \.element) { index, wording in + choiceRow( + title: AidenBotChatActivityCopy.label(wording), + detail: AidenBotChatActivityCopy.detail(wording), + recommended: false, + selected: model.value.wording == wording, + neighbors: neighbors(wordings.map { model.value.wording == $0 }, index), + preview: AidenBotChatActivityPreviewView(level: .quiet, wording: wording) + ) { Task { await model.pick(AidenBotChatActivity(level: model.value.level, wording: wording)) } } + } + } + Section { + AidenBotChatActivityPreviewView(level: .quiet, wording: model.value.wording) + .listRowBackground(palette.raised) + } header: { + Text(AidenBotChatActivityCopy.phonesTitle) + } footer: { + Text(AidenBotChatActivityCopy.phonesFooter) + } + } + .scrollContentBackground(.hidden) + .background(palette.canvas) + .navigationTitle(AidenBotChatActivityCopy.title) + .navigationBarTitleDisplayMode(.inline) + .disabled(!canWrite) + .alert( + "Couldn’t Save", + isPresented: Binding(get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } }) + ) { + Button("OK", role: .cancel) { model.errorMessage = nil } + } message: { + Text(model.errorMessage ?? AidenBotChatActivityCopy.saveFailed) + } + } + + /// Which separators touch the selected row; those are hidden so no sliver shows above it. + private func neighbors(_ selected: [Bool], _ index: Int) -> (top: Bool, bottom: Bool) { + let above = index > 0 && selected[index - 1] + let below = index < selected.count - 1 && selected[index + 1] + return (selected[index] || above, selected[index] || below) + } + + /// A radio row: the radio and the row's background show the choice; there is no border. + /// The row reads as one element (title, Recommended, detail); the preview is decoration. + private func choiceRow( + title: String, + detail: String, + recommended: Bool, + selected: Bool, + neighbors: (top: Bool, bottom: Bool), + preview: AidenBotChatActivityPreviewView, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: selected ? "largecircle.fill.circle" : "circle") + .foregroundStyle(selected ? palette.accent : palette.secondary) + .font(.title3) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(title).foregroundStyle(palette.foreground) + if recommended { + Text(AidenBotChatActivityCopy.recommended) + .font(.caption.weight(.medium)) + .foregroundStyle(palette.secondary) + } + } + Text(detail).font(.footnote).foregroundStyle(palette.secondary) + preview + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + // No dedicated selection token exists; the accent wash is what Appearance and onboarding use. + .listRowBackground( + ZStack { + palette.raised + if selected { palette.accent.opacity(0.12) } + } + ) + .listRowSeparator(neighbors.top ? .hidden : .automatic, edges: .top) + .listRowSeparator(neighbors.bottom ? .hidden : .automatic, edges: .bottom) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(selected ? .isSelected : []) + } +} + +/// A small chat replaying one scripted turn. It holds its still frame under Reduce Motion, +/// and stops while the app is inactive or the preview is scrolled out of view. +struct AidenBotChatActivityPreviewView: View { + let level: AidenBotChatActivityLevel + let wording: AidenBotChatActivityWording + + @Environment(\.aidenPalette) private var palette + @Environment(\.accessibilityReduceMotion) private var systemReduceMotion + @Environment(\.aidenReduceMotion) private var appReduceMotion + @Environment(\.scenePhase) private var scenePhase + @State private var onScreen = true + @ScaledMetric(relativeTo: .caption) private var markSize: CGFloat = 14 + + private var still: Bool { systemReduceMotion || appReduceMotion } + private var paused: Bool { still || scenePhase != .active || !onScreen } + + var body: some View { + // One shared clock, so every preview on the page is on the same step of the turn. + TimelineView(.animation(minimumInterval: 0.2, paused: paused)) { context in + let index = still + ? AidenBotChatActivityPreview.stillIndex(for: level) + : AidenBotChatActivityPreview.frameIndex(elapsed: context.date.timeIntervalSinceReferenceDate) + content(AidenBotChatActivityPreview.frame(index: index, level: level)) + } + .padding(10) + .background(palette.canvas, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + // A Form's rows come and go as they scroll, so appearing is being on screen. + .onAppear { onScreen = true } + .onDisappear { onScreen = false } + .accessibilityHidden(true) + } + + @ViewBuilder + private func content(_ frame: AidenBotChatActivityPreview.Frame) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(AidenBotChatActivityPreview.prompt) + .font(.caption) + .foregroundStyle(palette.foreground) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(palette.raised, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .frame(maxWidth: .infinity, alignment: .trailing) + if let recap = frame.recap { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: frame.recapOpen ? "chevron.down" : "chevron.right") + .font(.caption2.weight(.semibold)) + Text(recap).fixedSize(horizontal: false, vertical: true) + } + .font(.caption2) + .foregroundStyle(palette.secondary) + } + ForEach(frame.steps, id: \.self) { step in + Text(step).font(.caption2).foregroundStyle(palette.secondary).padding(.leading, 14) + } + if let running = frame.running { + Text(running).font(.caption2.weight(.medium)).foregroundStyle(palette.foreground).padding(.leading, 14) + } + if let phase = frame.phase { + let shown = AidenBotActivityPhase.display(phase: phase, wording: wording, app: nil) + HStack(spacing: 6) { + AidenActivityMark(mark: shown.mark, size: markSize, active: !paused, color: palette.secondary) + Text(shown.word).font(.caption) + } + .foregroundStyle(palette.secondary) + } else { + Text(AidenBotChatActivityPreview.reply).font(.caption).foregroundStyle(palette.foreground) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift index 83d6cc71d..edf93348c 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift @@ -1,329 +1,779 @@ +import Observation import SwiftUI -private struct AidenBotCreateAttempt: Equatable { - let context: AidenRemoteRequestContext - let request: AidenBotCreateRequest - let idempotencyKey: UUID - let chatIdempotencyKey: UUID +/// What New Bot was asked for, kept so a failed create can be tried again as it was. +struct AidenBotCreateInput: Codable, Equatable, Sendable { + var name: String + var purpose: String + /// The model the person picked; nil uses the Mac's recommended one. + var draft: AidenBotEditorDraft? +} + +/// A New Bot on its way, kept on disk per pairing (in `AidenBotOutboxStore`, with the +/// outbox's protection and purge) so a relaunch mid-create never loses it (R15, R17). +struct AidenBotPendingCreate: Codable, Equatable, Sendable { + /// The request as it first went out, with its keys: a retry sends exactly this. + struct Frozen: Codable, Equatable, Sendable { + let request: AidenBotCreateRequest + /// The model the request was built from, for the chat step. + let draft: AidenBotEditorDraft? + let key: UUID + let chatKey: UUID + /// The Bot, once the Mac made it. + var created: AidenBotDetail? + } + + let input: AidenBotCreateInput + /// Nil until the request was built: nothing of it can have reached the Mac. + var frozen: Frozen? + /// When this ask was made, or its request built: the replay window starts here. + let createdAt: Date + /// A request that may have reached the Mac, kept past the replay window: its keys are gone, so + /// it never goes out again as it was, and Try Again first looks for the Bot it may have made. + var lapsed: AidenBotCreateRequest? = nil + + /// The Mac forgets a Create's Idempotency-Key 24 hours after it answered, and then runs the + /// same key again. Within this window, an hour short of that for the two clocks, the same key + /// gets the Mac's one Bot back; past it, the same key could make a second. + static let replayWindow: TimeInterval = 23 * 60 * 60 + /// How far apart the phone's and the Mac's clocks may be (as on Android). + static let clockSkew: TimeInterval = 2 * 60 + + /// True while a request first sent at `sentAt` may go out again on its key. A clock that went + /// back since can't tell how long it has been, so that is past the window too. + static func canReplay(sentAt: Date, now: Date) -> Bool { + let age = now.timeIntervalSince(sentAt) + return age > -clockSkew && age < replayWindow + } +} + +/// The requests one Bot create makes; the remote client provides them, tests a fake. +struct AidenBotCreateOperations { + /// The pairing the create is for: a retry matches only its own attempt, and + /// what waits on disk is kept under it. + var instanceId: String + var deviceId: String + var usesDurableSession: Bool + var waitForConnection: @MainActor (Duration) async -> Bool + var catalog: @MainActor () async throws -> AidenBotCapabilityCatalog + var create: @MainActor (AidenBotCreateRequest, UUID) async throws -> AidenBotDetail + /// Older Macs need the Bot chat created explicitly. + var createChat: @MainActor (_ botID: String, AidenBotChatCreateRequest, UUID) async throws -> Void + var readBot: @MainActor (_ botID: String) async throws -> AidenBotDetail + /// The pairing the create started on is still the active one. + var isCurrent: @MainActor () -> Bool = { true } + /// True when the error revoked this phone; the create then stops quietly. + var handleRevocation: @MainActor (Error) async -> Bool = { _ in false } + + var scope: String { AidenBotCreateFlow.scope(instanceId: instanceId, deviceId: deviceId) } +} + +/// Bot creation that never waits on the sheet (R15). Create closes New Bot at +/// once and the Bot shows in Bots as on its way; it goes out after a short wait +/// for the Mac, and a failure takes the row away with an error and Try Again, +/// which reopens New Bot as it was. The same request keeps its Idempotency-Key +/// (and a Bot already made) across tries, so a retry never makes two Bots. +/// +/// The ask is on disk before anything goes out and until the Bot is made or the +/// person lets it go, so a relaunch mid-create brings it back to Bots home as not +/// made, and its Try Again sends the same request on the same key. When it can't be +/// written nothing goes out, and the failure says so. +/// +/// Everything a create leaves behind is its pairing's alone: Bots home shows only the +/// active pairing's, and Try Again sends only to the Mac it was for, so one Mac's +/// answers never become a Bot on another. +@MainActor +@Observable +final class AidenBotCreateFlow { + struct Pending: Equatable, Identifiable { + let id: UUID + let name: String + let purpose: String + /// The pairing it is on its way to; Bots home shows it there only. + let instanceId: String + let deviceId: String + + var scope: String { AidenBotCreateFlow.scope(instanceId: instanceId, deviceId: deviceId) } + } + + struct Failure: Equatable { + let message: String + var input: AidenBotCreateInput + /// An earlier launch's create that may have reached the Mac: Try Again sends + /// its frozen request again rather than reopening New Bot. + var replays = false + /// The pairing it was for. + var instanceId = "" + var deviceId = "" + /// The request that may have reached the Mac without an answer, and when it first went + /// out: past the replay window, Try Again looks for its Bot instead of sending it again. + var sent: AidenBotCreateRequest? + var sentAt: Date? + } + + static let unreachable = "Aiden couldn’t reach your Mac, so the Bot wasn’t made. Try again." + static let interrupted = "Aiden closed before the Bot was ready. Try again to finish making it." + static let notKept = "Aiden couldn’t store this Bot on your iPhone, so it wasn’t sent to your Mac. Try again." + + /// What Try Again on Bots home does with a failure. + enum Retry: Equatable { + /// Send it again as it was: an earlier launch's, on its key. Hand its input to `submit`. + case send(AidenBotCreateInput) + /// Reopen New Bot with these answers. + case reopen(Failure) + /// Nothing goes out: the Mac's list shows the Bot it may have made, so it is let go quietly. + case settled + /// Nothing: it is another Mac's, and waits for that Mac. + case none + } + + private(set) var pending: Pending? + /// Each pairing's failed create, kept for that pairing alone. + private var failures: [String: Failure] = [:] + + private struct Attempt { + let scope: String + /// What the person asked for; a retry is the same attempt while this is unchanged. + let input: AidenBotCreateInput + var frozen: AidenBotPendingCreate.Frozen + let createdAt: Date + /// Left by an earlier launch: New Bot can't be trusted to rebuild the same ask, so Try Again replays it. + var restored = false + } + + /// Each pairing's last attempt, kept for a retry of the same ask. + @ObservationIgnored private var retained: [String: Attempt] = [:] + @ObservationIgnored private let connectionHold: Duration + @ObservationIgnored private let requestLimit: Duration + @ObservationIgnored private let store: AidenBotOutboxStore? + @ObservationIgnored private let now: () -> Date + /// The disk writes, made one after another in the order they were asked for; each says whether it was kept. + @ObservationIgnored private var writes: Task? + /// Pairings this flow already looked on disk for; an earlier launch's create comes back once. + @ObservationIgnored private var restoredScopes: Set = [] + /// The look on disk under way for each pairing; a newer one supersedes it. + @ObservationIgnored private var restoring: [String: UUID] = [:] + /// Pairings a create of this launch is sending for, in any flow: theirs is never shown as cut off. + private static var sending: Set = [] + + init( + connectionHold: Duration = .seconds(8), + requestLimit: Duration = .seconds(12), + store: AidenBotOutboxStore? = .shared, + now: @escaping () -> Date = { Date() } + ) { + self.connectionHold = connectionHold + self.requestLimit = requestLimit + self.store = store + self.now = now + } + + nonisolated static func scope(instanceId: String, deviceId: String) -> String { "\(instanceId)|\(deviceId)" } + + /// The failed create Bots home shows for this pairing; another pairing's is never shown here. + func failure(instanceId: String, deviceId: String) -> Failure? { + failures[Self.scope(instanceId: instanceId, deviceId: deviceId)] + } + + /// Makes the Bot; returns once it exists on the Mac (and `onCreated` ran) or failed, + /// with what waits on disk written. `retrying` is the failure a Try Again (or the New Bot + /// it reopened) came from: it is sent only to its own Mac, and otherwise waits for it. + func submit( + _ input: AidenBotCreateInput, + operations: AidenBotCreateOperations, + retrying: Failure? = nil, + onCreated: @MainActor (AidenBotDetail) async -> Void + ) async { + let ownMac = retrying.map { $0.instanceId == operations.instanceId && $0.deviceId == operations.deviceId } ?? true + guard pending == nil, ownMac else { + // Another Bot is still on its way (one is made at a time), or another Mac is the active + // one now: nothing goes out. A Try Again's answers wait as they were, for their own Mac. + if let retrying { + let scope = Self.scope(instanceId: retrying.instanceId, deviceId: retrying.deviceId) + if failures[scope] == nil { + var kept = retrying + kept.input = input + failures[scope] = kept + } + } + return + } + await make(input, operations: operations, onCreated: onCreated) + _ = await writes?.value + } + + /// Try Again on `failure` while the pairing (`instanceId`, `deviceId`) is the active one. + /// Another Mac's failure does nothing here and stays for its own Mac. `bots` is that pairing's + /// list, for a request that may have reached the Mac past the replay window: if the list shows + /// its Bot it is let go quietly; otherwise New Bot reopens with its answers, and Create there + /// is a new request. It never goes out again on a key the Mac may have forgotten. + func retry(_ failure: Failure, instanceId: String, deviceId: String, bots: [AidenBotSummary] = []) -> Retry { + guard failure.instanceId == instanceId, failure.deviceId == deviceId else { return .none } + let scope = Self.scope(instanceId: instanceId, deviceId: deviceId) + if failures[scope] == failure { failures[scope] = nil } + guard let sent = failure.sent, let sentAt = failure.sentAt, + !AidenBotPendingCreate.canReplay(sentAt: sentAt, now: now()) else { + // An earlier launch's create: the same request, on the same key. + return failure.replays ? .send(failure.input) : .reopen(failure) + } + // Its key is past the Mac's memory: it is never sent again. + if let held = retained[scope], held.frozen.created == nil, Self.isSameAsk(held, failure.input) { + retained[scope] = nil + } + if Self.listShows(sent, sentAt: sentAt, in: bots) { + // The Mac made it after all: nothing is left to try again. + if pending?.scope != scope, !Self.sending.contains(scope) { + persist(nil, instanceId: instanceId, deviceId: deviceId) + } + return .settled + } + // The person sees the answers in New Bot and decides. + var reopened = failure + reopened.replays = false + return .reopen(reopened) + } + + /// True when the Mac's list shows the Bot `sent` may have made: the same name and subtitle, + /// made since it went out, allowing for the two clocks (as Android's `aidenBotCreateWasTaken`). + nonisolated static func listShows(_ sent: AidenBotCreateRequest, sentAt: Date, in bots: [AidenBotSummary]) -> Bool { + let since = sentAt.addingTimeInterval(-AidenBotPendingCreate.clockSkew) + return bots.contains { $0.name == sent.name && $0.purpose == sent.purpose && $0.createdAt >= since } + } + + private func make( + _ input: AidenBotCreateInput, + operations: AidenBotCreateOperations, + onCreated: @MainActor (AidenBotDetail) async -> Void + ) async { + let scope = operations.scope + failures[scope] = nil + pending = Pending( + id: UUID(), + name: input.name.trimmingCharacters(in: .whitespacesAndNewlines), + purpose: input.purpose.trimmingCharacters(in: .whitespacesAndNewlines), + instanceId: operations.instanceId, + deviceId: operations.deviceId + ) + Self.sending.insert(scope) + defer { + pending = nil + Self.sending.remove(scope) + } + // The same ask as a failed try: its frozen request and key, whatever the catalog says now, + // while the Mac still keeps that key. A Bot already made is never made again. + let held = retained[scope].flatMap { Self.isSameAsk($0, input) ? $0 : nil } + let replay = held.flatMap { + $0.frozen.created != nil || AidenBotPendingCreate.canReplay(sentAt: $0.createdAt, now: now()) ? $0 : nil + } + // Past it, the same key could make a second Bot: this Create is a new request. + if held != nil, replay == nil { retained[scope] = nil } + // On disk before anything goes out, so a relaunch never loses it; nothing goes out when it can't be. + guard await persist( + AidenBotPendingCreate(input: input, frozen: replay?.frozen, createdAt: replay?.createdAt ?? now()), + operations + ).value else { + failures[scope] = makeFailure(Self.notKept, input, operations) + return + } + var sent = false + do { + // Another Mac became the active one meanwhile: nothing goes out, and the ask waits for its own Mac. + guard await operations.waitForConnection(connectionHold), operations.isCurrent() else { + failures[scope] = makeFailure(Self.unreachable, input, operations) + return + } + var attempt: Attempt + if let replay { + attempt = replay + } else { + let (request, draft) = try await resolve(input, operations: operations) + attempt = Attempt( + scope: scope, + input: input, + frozen: AidenBotPendingCreate.Frozen(request: request, draft: draft, key: UUID(), chatKey: UUID()), + createdAt: now() + ) + // Its keys reach the disk before the request reaches the Mac. + guard await persist(record(attempt), operations).value else { + failures[scope] = makeFailure(Self.notKept, input, operations) + return + } + } + retained[scope] = attempt + sent = true + let created: AidenBotDetail + if let made = attempt.frozen.created { + created = made + } else { + let frozen = attempt.frozen + created = try await bounded { try await operations.create(frozen.request, frozen.key) } + attempt.frozen.created = created + retained[scope] = attempt + persist(record(attempt), operations) + } + guard operations.isCurrent() else { return } + if !operations.usesDurableSession, let draft = attempt.frozen.draft { + let chat = try AidenBotChatCreateRequest( + providerId: draft.customAccess.providerID, + modelId: draft.customAccess.modelID + ) + let chatKey = attempt.frozen.chatKey + try await bounded { try await operations.createChat(created.id, chat, chatKey) } + guard operations.isCurrent() else { return } + } + let authoritative = try await bounded { try await operations.readBot(created.id) } + guard operations.isCurrent() else { return } + retained[scope] = nil + persist(nil, operations) + await onCreated(authoritative) + } catch is CancellationError { + return + } catch { + if await operations.handleRevocation(error) { + retained[scope] = nil + persist(nil, operations) + return + } + if Self.isAccessLoss(error) { + // Access ended: nothing of this ask is kept on disk. + retained[scope] = nil + persist(nil, operations) + } else if sent, retained[scope]?.frozen.created == nil, !aidenBotEditorCreateFailureIsAmbiguous(error) { + // Refused before anything was made: a retry is a new request. The answers stay. + retained[scope] = nil + persist(AidenBotPendingCreate(input: input, frozen: nil, createdAt: now()), operations) + } + // A Bot already made, or a request that may have arrived, is tried again as it was, + // and only on its own Mac: Bots home shows it once that Mac is the active one. + failures[scope] = makeFailure(aidenBotFriendlyLoadError(error), input, operations) + } + } + + /// Brings back the New Bot an earlier launch left on its way for this pairing: Bots home + /// shows it as not made, and Try Again sends the same request on the same key. It looks + /// once per pairing, and never over a create or failure of this launch. + /// + /// `isCurrent` says the pairing is still the active one, as its request context does. A look + /// that lands after Bots home moved on (its task cancelled) or after another Mac became the + /// active one shows nothing; the ask stays on disk and comes back when its Mac is active again. + func restore(instanceId: String, deviceId: String, isCurrent: @MainActor () -> Bool = { true }) async { + let scope = Self.scope(instanceId: instanceId, deviceId: deviceId) + guard let store, isFree(for: scope), !restoredScopes.contains(scope) else { return } + let look = UUID() + restoring[scope] = look + _ = await writes?.value + let saved = await store.pendingCreate(instanceId: instanceId, deviceId: deviceId) + // A newer look for this pairing is under way: it decides. + guard restoring[scope] == look else { return } + restoring[scope] = nil + guard !Task.isCancelled, isCurrent() else { return } + restoredScopes.insert(scope) + guard let saved, isFree(for: scope) else { return } + if let frozen = saved.frozen { + retained[scope] = Attempt(scope: scope, input: saved.input, frozen: frozen, createdAt: saved.createdAt, restored: true) + } + // What may have reached the Mac without an answer: past the replay window (now, or while + // this prompt stays open) Try Again looks for its Bot rather than sending it again. + let unanswered = saved.frozen.flatMap { $0.created == nil ? $0.request : nil } ?? saved.lapsed + failures[scope] = Failure( + message: Self.interrupted, + input: saved.input, + replays: saved.frozen != nil, + instanceId: instanceId, + deviceId: deviceId, + sent: unanswered, + sentAt: unanswered == nil ? nil : saved.createdAt + ) + } + + /// The person let the failed Bot go (OK, or New Bot closed after Try Again): it no + /// longer waits on disk. In memory its key stays for the same ask, so asking again + /// can't make a second Bot. Returns the write, for tests. + @discardableResult + func discard(_ failure: Failure) -> Task? { + let scope = Self.scope(instanceId: failure.instanceId, deviceId: failure.deviceId) + if failures[scope] == failure { failures[scope] = nil } + guard pending?.scope != scope, !Self.sending.contains(scope) else { return nil } + return persist(nil, instanceId: failure.instanceId, deviceId: failure.deviceId) + } + + private func isFree(for scope: String) -> Bool { + pending?.scope != scope && failures[scope] == nil && retained[scope] == nil && !Self.sending.contains(scope) + } + + /// A restored create that fails again keeps replaying its frozen request on Try Again. + private func makeFailure(_ message: String, _ input: AidenBotCreateInput, _ operations: AidenBotCreateOperations) -> Failure { + let attempt = retained[operations.scope].flatMap { Self.isSameAsk($0, input) ? $0 : nil } + let unanswered = attempt.flatMap { $0.frozen.created == nil ? $0 : nil } + return Failure( + message: message, + input: input, + replays: attempt?.restored ?? false, + instanceId: operations.instanceId, + deviceId: operations.deviceId, + sent: unanswered?.frozen.request, + sentAt: unanswered?.createdAt + ) + } + + private func record(_ attempt: Attempt) -> AidenBotPendingCreate { + AidenBotPendingCreate(input: attempt.input, frozen: attempt.frozen, createdAt: attempt.createdAt) + } + + @discardableResult + private func persist(_ create: AidenBotPendingCreate?, _ operations: AidenBotCreateOperations) -> Task { + persist(create, instanceId: operations.instanceId, deviceId: operations.deviceId) + } + + /// Queues a write behind the ones before it; nil removes what waits. Its value is false when + /// `create` could not be kept on disk (without a store there is nothing to keep). + @discardableResult + private func persist(_ create: AidenBotPendingCreate?, instanceId: String, deviceId: String) -> Task { + let previous = writes + let store = store + let task = Task { () -> Bool in + _ = await previous?.value + guard let store else { return true } + guard let create else { + await store.removePendingCreate(instanceId: instanceId, deviceId: deviceId) + return true + } + return await store.savePendingCreate(create, instanceId: instanceId, deviceId: deviceId) + } + writes = task + return task + } + + /// 401 or 403, or a credential that's gone: this phone can no longer make Bots on this + /// Mac. A pairing that merely stopped being the active one is not access loss; its ask + /// stays for when it is active again. + private static func isAccessLoss(_ error: Error) -> Bool { + guard let error = error as? AidenRemoteClientError else { return false } + switch error { + case let .server(status, _), let .unexpectedStatus(status): + return status == 401 || status == 403 + case .missingCredential: + return true + default: + return false + } + } + + /// The request for `input`: its picked model, or the Mac's recommended one, + /// or none on a Mac that lets a Bot start without one. + private func resolve( + _ input: AidenBotCreateInput, + operations: AidenBotCreateOperations + ) async throws -> (AidenBotCreateRequest, AidenBotEditorDraft?) { + let name = input.name.trimmingCharacters(in: .whitespacesAndNewlines) + let purpose = input.purpose.trimmingCharacters(in: .whitespacesAndNewlines) + let catalog = try await bounded { try await operations.catalog() } + var draft = input.draft + if draft == nil { + do { + draft = try aidenBotEditorResolvedDraft(mode: .create(defaultAccess: .recommended), catalog: catalog, bot: nil) + } catch AidenBotContractError.invalidCombination("no available provider and model") where operations.usesDurableSession { + let request = try AidenBotCreateRequest( + name: name, + purpose: purpose, + instructions: AidenBotEditorDraft.seededInstructions(helpWith: purpose), + avatar: .recipe(AidenBotEditorDraft.defaultAvatar), + access: nil + ) + return (request, nil) + } + } + guard var draft else { throw AidenBotContractError.invalidCombination("no available provider and model") } + draft.name = name + draft.purpose = purpose + draft.instructions = AidenBotEditorDraft.seededInstructions(helpWith: purpose) + return (try draft.createRequest(catalog: catalog), draft) + } + + /// The same ask as `attempt`, decided before any catalog is read: the same answers, or, when the + /// first try went out before the Mac's models arrived, the model it was made with. A catalog that + /// moved on since is not a change the person made. + private static func isSameAsk(_ attempt: Attempt, _ input: AidenBotCreateInput) -> Bool { + if attempt.input == input { return true } + guard attempt.input.name == input.name, attempt.input.purpose == input.purpose, + attempt.input.draft == nil, let made = attempt.frozen.draft, let draft = input.draft else { return false } + return made.customAccess.providerID == draft.customAccess.providerID + && made.customAccess.modelID == draft.customAccess.modelID + && made.visionProviderID == draft.visionProviderID + && made.visionModelID == draft.visionModelID + } + + private func bounded(_ operation: @escaping @MainActor () async throws -> T) async throws -> T { + let call = AidenBotMainActorCall(operation) + return try await aidenBotWithin(requestLimit) { try await call.call() } + } } /// Two questions: a name, and what the Bot should help with. The answer /// becomes its subtitle and first instructions; everything else uses /// sensible defaults that can be changed later in the Profile. +/// +/// It opens at once (R15): the questions are there before the Mac answers, the +/// AI model row is a skeleton until the Mac's models arrive (from the cache +/// first), and Create works while connecting. Create hands the Bot to `onCreate` +/// and closes; the Bot is made after, without holding the sheet. struct AidenBotCreateView: View { @Bindable var coordinator: AidenRemoteCoordinator - var onCreated: (AidenBotDetail) -> Void = { _ in } + /// What a Try Again brings back. + var initial: AidenBotCreateInput? + var onCreate: (AidenBotCreateInput) -> Void = { _ in } @Environment(\.dismiss) private var dismiss @Environment(\.aidenPalette) private var palette + @Environment(\.aidenReduceMotion) private var reduceMotion @State private var catalog: AidenBotCapabilityCatalog? @State private var draft: AidenBotEditorDraft? - @State private var capturedContext: AidenRemoteRequestContext? - @State private var isLoading = true + @State private var name = "" + @State private var purpose = "" + @State private var didPrefill = false @State private var loadError: String? - @State private var saveError: String? - @State private var activeAttempt: AidenBotCreateAttempt? - @State private var retainedAttempt: AidenBotCreateAttempt? - @State private var retainedCreatedBot: AidenBotDetail? /// A durable-session host lets a Bot start without an AI model; it then /// reports `needs_model` until one is chosen on the Mac. @State private var allowsModellessCreate = false - @State private var modellessName = "" - @State private var modellessPurpose = "" @FocusState private var nameIsFocused: Bool private var sessionIdentity: AidenBotCustomAccessSessionIdentity { AidenBotCustomAccessSessionIdentity(coordinator: coordinator) } - private var isSaving: Bool { activeAttempt != nil } - - private var canCreate: Bool { - guard capturedContext.map(coordinator.isCurrent) == true, - coordinator.connectionState == .connected, - coordinator.installationStore.activeInstallation?.canWriteBots == true, - !isSaving else { return false } - if let catalog, let draft { return draft.isSaveable(catalog: catalog) } - return allowsModellessCreate && (try? modellessRequest()) != nil - } - private var usesDurableSession: Bool { AidenBotHostFeature.isAdvertised(AidenBotHostFeature.durableSession, coordinator: coordinator) } - private func modellessRequest() throws -> AidenBotCreateRequest { - let purpose = modellessPurpose.trimmingCharacters(in: .whitespacesAndNewlines) - return try AidenBotCreateRequest( - name: modellessName.trimmingCharacters(in: .whitespacesAndNewlines), - purpose: purpose, - instructions: AidenBotEditorDraft.seededInstructions(helpWith: purpose), - avatar: .recipe(AidenBotEditorDraft.defaultAvatar), - access: nil - ) + private var input: AidenBotCreateInput { + var picked = draft + picked?.name = name + picked?.purpose = purpose + return AidenBotCreateInput(name: name, purpose: purpose, draft: picked) + } + + /// Valid answers are enough: the model is the picked one, or the Mac's + /// recommended one once it answers. + private var canCreate: Bool { + guard coordinator.installationStore.activeInstallation?.canWriteBots == true, + (try? AidenBotCreateRequest( + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + purpose: purpose.trimmingCharacters(in: .whitespacesAndNewlines), + instructions: AidenBotEditorDraft.seededInstructions(helpWith: purpose), + avatar: .recipe(AidenBotEditorDraft.defaultAvatar), + access: nil + )) != nil else { return false } + if let catalog, let draft { + var named = draft + named.name = name + named.purpose = purpose + named.instructions = AidenBotEditorDraft.seededInstructions(helpWith: purpose) + return named.isSaveable(catalog: catalog) + } + return true } var body: some View { NavigationStack { - content + form .navigationTitle("New Bot") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } - .disabled(isSaving) } ToolbarItem(placement: .confirmationAction) { - Button(isSaving ? "Creating…" : "Create") { - Task { await create() } + Button("Create") { + onCreate(input) + dismiss() } .disabled(!canCreate) } } } - .interactiveDismissDisabled(isSaving) - .task(id: sessionIdentity) { + .task(id: sessionIdentity.pairing) { + prefill() await load() } - .alert( - "Couldn’t Create Bot", - isPresented: Binding( - get: { saveError != nil }, - set: { if !$0 { saveError = nil } } - ) - ) { - Button("OK", role: .cancel) { saveError = nil } - } message: { - Text(saveError ?? "Please try again.") + .onChange(of: coordinator.connectionState == .connected) { _, connected in + if connected, catalog == nil || loadError != nil { Task { await load() } } + } + } + + private var form: some View { + Form { + Section { + TextField("Name", text: $name) + .textContentType(.name) + .submitLabel(.next) + .focused($nameIsFocused) + TextField("What should it help with?", text: $purpose, axis: .vertical) + .lineLimit(2...5) + } footer: { + Text(accessNote) + } + modelSection } + .scrollContentBackground(.hidden) + .background(palette.canvas) + .onAppear { nameIsFocused = true } } @ViewBuilder - private var content: some View { - if draft != nil || allowsModellessCreate { - Form { - Section { - TextField("Name", text: binding(\.name)) - .textContentType(.name) - .submitLabel(.next) - .focused($nameIsFocused) - TextField("What should it help with?", text: binding(\.purpose), axis: .vertical) - .lineLimit(2...5) - } footer: { - Text(accessNote) + private var modelSection: some View { + if let catalog, let current = draft { + Section { + AidenBotModelRow( + title: "Model", + catalog: catalog, + providerID: current.customAccess.providerID, + modelID: current.customAccess.modelID, + recommended: AidenBotCustomAccessDraft(catalog: catalog).map { + AidenBotModelSelection(providerId: $0.providerID, modelId: $0.modelID) + } + ) { providerID, modelID in + draft?.selectModel(providerID: providerID, modelID: modelID, catalog: catalog) } - if let catalog, let current = draft { - Section { - AidenBotModelRow( - title: "Model", - catalog: catalog, - providerID: current.customAccess.providerID, - modelID: current.customAccess.modelID, - recommended: AidenBotCustomAccessDraft(catalog: catalog).map { - AidenBotModelSelection(providerId: $0.providerID, modelId: $0.modelID) - } - ) { providerID, modelID in - // Re-confirming the checked row changes nothing, so a committed Bot's recovery survives. - let before = draft - draft?.selectModel(providerID: providerID, modelID: modelID, catalog: catalog) - if draft != before { - retainedAttempt = nil - retainedCreatedBot = nil - } - } - switch current.photoModelNeed(catalog: catalog) { - case .none: - EmptyView() - case .unavailable: - Text(current.noImageModelNote(catalog: catalog)) - .foregroundStyle(palette.secondary) - case .choose: - AidenBotModelRow( - title: "Photos", - catalog: catalog, - providerID: current.visionProviderID, - modelID: current.visionModelID, - imagesOnly: true - ) { providerID, modelID in - let before = draft - draft?.selectVisionModel(providerID: providerID, modelID: modelID, catalog: catalog) - if draft != before { - retainedAttempt = nil - retainedCreatedBot = nil - } - } - } - } header: { - Text("AI model") - } footer: { - Text("You can change this later in Advanced.") + switch current.photoModelNeed(catalog: catalog) { + case .none: + EmptyView() + case .unavailable: + Text(current.noImageModelNote(catalog: catalog)) + .foregroundStyle(palette.secondary) + case .choose: + AidenBotModelRow( + title: "Photos", + catalog: catalog, + providerID: current.visionProviderID, + modelID: current.visionModelID, + imagesOnly: true + ) { providerID, modelID in + draft?.selectVisionModel(providerID: providerID, modelID: modelID, catalog: catalog) } } + } header: { + Text("AI model") + } footer: { + Text("You can change this later in Advanced.") } - .scrollContentBackground(.hidden) - .background(palette.canvas) - .disabled(isSaving) - .onAppear { nameIsFocused = true } - } else if isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(palette.canvas) - } else { - ContentUnavailableView { - Label("Can’t Create a Bot Yet", systemImage: "exclamationmark.bubble") - } description: { - Text(loadError ?? "Connect to your Mac, then try again.") - } actions: { - Button("Try Again") { Task { await load() } } + } else if !allowsModellessCreate { + Section { + if let loadError { + // Never a blocking screen: the Bot is made with the recommended model. + HStack(spacing: 8) { + Text(loadError) + .font(.footnote) + .foregroundStyle(palette.secondary) + Spacer(minLength: 8) + Button("Try Again") { Task { await load() } } + .font(.footnote.weight(.semibold)) + .frame(minHeight: 44) + } + } else { + // The Mac's models are on their way: a skeleton row, never a spinner. + HStack { + AidenBotSkeletonBlock(width: 64, height: 16, radius: 8, reduceMotion: reduceMotion) + Spacer() + AidenBotSkeletonBlock(width: 120, height: 16, radius: 8, reduceMotion: reduceMotion) + } + .frame(minHeight: 28) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Loading models") + } + } header: { + Text("AI model") + } footer: { + Text("It uses the recommended model unless you pick one.") } } } private var accessNote: String { - if draft == nil { + if allowsModellessCreate { return "It needs an AI model before it can reply. Choose one on your Mac." } return "It can use everything your Mac allows. You can change this later in Advanced." } - private func binding(_ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { - if let draft { return draft[keyPath: keyPath] } - return keyPath == \AidenBotEditorDraft.name ? modellessName : modellessPurpose - }, - set: { value in - retainedAttempt = nil - retainedCreatedBot = nil - guard var next = draft else { - if keyPath == \AidenBotEditorDraft.name { modellessName = value } else { modellessPurpose = value } - return - } - next[keyPath: keyPath] = value - if keyPath == \AidenBotEditorDraft.purpose { - next.instructions = AidenBotEditorDraft.seededInstructions(helpWith: value) - } - draft = next - retainedAttempt = nil - retainedCreatedBot = nil - } - ) + private func prefill() { + guard !didPrefill else { return } + didPrefill = true + guard let initial else { return } + name = initial.name + purpose = initial.purpose + draft = initial.draft } + /// The Mac's models, from the cache at once and then fresh. A picked model stays. @MainActor private func load() async { - let expectedSession = sessionIdentity - isLoading = true - loadError = nil - var requestContext: AidenRemoteRequestContext? + let expected = sessionIdentity.pairing + guard let context = try? coordinator.requestContext() else { return } + if catalog == nil, + let cached = await AidenBotCache.shared.load(instanceId: context.instanceId, deviceId: context.deviceId)?.catalog, + sessionIdentity.pairing == expected { + adopt(cached) + } do { - let context = try coordinator.requestContext() - requestContext = context - let loadedCatalog = try await coordinator.remoteClient(for: context).botCapabilityCatalog() - guard coordinator.isCurrent(context), sessionIdentity == expectedSession, - !Task.isCancelled else { return } - let loadedDraft: AidenBotEditorDraft? - do { - loadedDraft = try aidenBotEditorResolvedDraft( - mode: .create(defaultAccess: .recommended), - catalog: loadedCatalog, - bot: nil - ) - } catch AidenBotContractError.invalidCombination("no available provider and model") where usesDurableSession { - loadedDraft = nil - } - capturedContext = context - catalog = loadedCatalog - allowsModellessCreate = loadedDraft == nil - if draft == nil { - draft = loadedDraft - } - isLoading = false + let loaded = try await coordinator.remoteClient(for: context).botCapabilityCatalog() + guard coordinator.isCurrent(context), sessionIdentity.pairing == expected, !Task.isCancelled else { return } + loadError = nil + adopt(loaded) } catch is CancellationError { return } catch { - if let context = requestContext, - await coordinator.handleCredentialRevocation(error, context: context) { return } - guard sessionIdentity == expectedSession else { return } - capturedContext = nil - loadError = aidenBotFriendlyLoadError(error) - isLoading = false + if await coordinator.handleCredentialRevocation(error, context: context) { return } + guard sessionIdentity.pairing == expected else { return } + if catalog == nil { loadError = aidenBotFriendlyLoadError(error) } } } - @MainActor - private func create() async { - guard canCreate, let context = capturedContext else { return } - var sentAttempt: AidenBotCreateAttempt? + private func adopt(_ loaded: AidenBotCapabilityCatalog) { + let resolved: AidenBotEditorDraft? do { - let request: AidenBotCreateRequest - if let catalog, let draft { - request = try draft.createRequest(catalog: catalog) - } else { - request = try modellessRequest() - } - let attempt: AidenBotCreateAttempt - if let retainedAttempt, retainedAttempt.context == context, retainedAttempt.request == request { - attempt = retainedAttempt - } else { - attempt = AidenBotCreateAttempt( - context: context, - request: request, - idempotencyKey: UUID(), - chatIdempotencyKey: UUID() - ) - } - retainedAttempt = attempt - activeAttempt = attempt - sentAttempt = attempt - defer { if activeAttempt == attempt { activeAttempt = nil } } - let client = try coordinator.remoteClient(for: attempt.context) - let created: AidenBotDetail - if let retainedCreatedBot { - created = retainedCreatedBot - } else { - created = try await client.createBot(attempt.request, idempotencyKey: attempt.idempotencyKey) - guard isCurrent(attempt) else { return } - retainedCreatedBot = created - } - // A durable-session host gives every Bot its one conversation; - // older hosts need the Bot chat created explicitly. - if !usesDurableSession, let draft { - _ = try await client.createBotChat( - botId: created.id, - request: try AidenBotChatCreateRequest( - providerId: draft.customAccess.providerID, - modelId: draft.customAccess.modelID - ), - idempotencyKey: attempt.chatIdempotencyKey - ) - guard isCurrent(attempt) else { return } - } - let authoritative = try await client.bot(id: created.id) - guard isCurrent(attempt) else { return } - retainedAttempt = nil - retainedCreatedBot = nil - onCreated(authoritative) - dismiss() - } catch is CancellationError { - return + resolved = try aidenBotEditorResolvedDraft(mode: .create(defaultAccess: .recommended), catalog: loaded, bot: nil) } catch { - if await coordinator.handleCredentialRevocation(error, context: context) { - if retainedAttempt == sentAttempt { retainedAttempt = nil } - return - } - guard coordinator.isCurrent(context), capturedContext == context else { return } - if retainedCreatedBot == nil, let sentAttempt, retainedAttempt == sentAttempt, - !aidenBotEditorCreateFailureIsAmbiguous(error) { - retainedAttempt = nil - } - saveError = error.localizedDescription + resolved = nil } + catalog = loaded + allowsModellessCreate = resolved == nil && usesDurableSession + // A model already picked stays while the Mac still offers it. + if let draft, draft.isSaveable(catalog: loaded) { return } + draft = resolved } +} - @MainActor - private func isCurrent(_ attempt: AidenBotCreateAttempt) -> Bool { - coordinator.isCurrent(attempt.context) - && capturedContext == attempt.context - && activeAttempt == attempt +/// The Bots row for a Bot on its way to the Mac (R15): its name at once, +/// a skeleton face, and "Creating…". +struct AidenBotPendingCreateRow: View { + let pending: AidenBotCreateFlow.Pending + + @Environment(\.aidenPalette) private var palette + @Environment(\.aidenReduceMotion) private var reduceMotion + + var body: some View { + HStack(alignment: .top, spacing: 14) { + AidenBotSkeletonBlock(width: 52, height: 52, radius: 26, reduceMotion: reduceMotion) + VStack(alignment: .leading, spacing: 4) { + Text(pending.name) + .font(.headline) + .foregroundStyle(palette.foreground) + .lineLimit(1) + Text(AidenBotCreateCopy.creating) + .font(.body) + .foregroundStyle(palette.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(pending.name), \(AidenBotCreateCopy.creating)") } } + +enum AidenBotCreateCopy { + static let creating = "Creating…" + static let failedTitle = "Couldn’t Create Bot" +} diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift index b1899d6c4..bd3e1587e 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift @@ -1020,10 +1020,24 @@ struct AidenBotCanonicalAvatarView: View { let name: String let size: CGFloat var isDecorative = true + /// The Bot is in a running turn: the face glances, or a photo wears a glance mark. + var working = false + @Environment(\.aidenPalette) private var palette + @Environment(\.accessibilityReduceMotion) private var systemReduceMotion + @Environment(\.aidenReduceMotion) private var appReduceMotion + @Environment(\.aidenLowPowerMode) private var lowPowerMode @State private var canonicalImage: UIImage? @State private var loadedCacheKey: AidenBotCanonicalAvatarCacheKey? + private var cue: AidenBotWorkingCue { + aidenBotWorkingCue( + working: working, + hasPhoto: canonicalImage != nil, + motionAllowed: !systemReduceMotion && !appReduceMotion && !lowPowerMode + ) + } + private var loadIdentity: AidenBotCanonicalAvatarLoadIdentity { AidenBotCanonicalAvatarLoadIdentity( coordinator: coordinator, @@ -1038,7 +1052,8 @@ struct AidenBotCanonicalAvatarView: View { avatar: avatar.semantic, name: name, size: size, - isDecorative: isDecorative + isDecorative: isDecorative, + working: cue.glances ) if let canonicalImage { Image(uiImage: canonicalImage) @@ -1049,6 +1064,14 @@ struct AidenBotCanonicalAvatarView: View { .accessibilityHidden(isDecorative) .accessibilityLabel(isDecorative ? "" : "\(name) Bot photo") } + // A photo can't glance, and a face holds still under Reduce Motion or Low + // Power: a small glance mark in its still pose says the Bot is working (R19). + if cue.badge { + AidenActivityMark(mark: .glance, size: size * 0.32, color: palette.foreground) + .padding(size * 0.04) + .background(palette.canvas, in: Circle()) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + } } .frame(width: size, height: size) .task(id: loadIdentity) { diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotMemoryView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotMemoryView.swift index 8cb7baec2..a85b65783 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotMemoryView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotMemoryView.swift @@ -81,8 +81,10 @@ protocol AidenBotMemoryTransport: Sendable { extension AidenRemoteClient: AidenBotMemoryTransport {} -/// Loads a Bot's memory and applies the person's edits. Every failed edit -/// refetches the view, because the Mac's error bodies carry none. +/// Loads a Bot's memory and applies the person's edits. Each edit shows at +/// once and goes to the Mac in order (R15); a failed one refetches the view, +/// because the Mac's error bodies carry none, or, if that fails too, goes +/// back to what showed before it. @MainActor @Observable final class AidenBotMemoryModel { @@ -92,13 +94,17 @@ final class AidenBotMemoryModel { private(set) var memory: AidenBotMemory? private(set) var isLoading = false - private(set) var isMutating = false + /// Edits on their way to the Mac. + private(set) var editsInFlight = 0 + var isMutating: Bool { editsInFlight > 0 } /// A load failure, shown in place of the page. private(set) var loadError: String? /// A failed delete or erase, shown as an alert on the page. var errorMessage: String? @ObservationIgnored private var retainedEdit: (edit: AidenBotMemoryEdit, key: UUID)? + /// The edit going to the Mac now; the next one waits for it. + @ObservationIgnored private var lane: Task? init(botID: String, botName: String, transport: any AidenBotMemoryTransport) { self.botID = botID @@ -110,21 +116,34 @@ final class AidenBotMemoryModel { var countSummary: String? { memory.map(AidenBotMemoryCopy.count) } - func load() async { + /// Returns true when the Mac's view replaced what showed. + @discardableResult + func load() async -> Bool { isLoading = true defer { isLoading = false } do { let loaded = try await transport.botMemory(botId: botID) - guard loaded.botId == botID else { return } + guard loaded.botId == botID else { return false } memory = loaded loadError = nil + return true } catch is CancellationError { - return + return false } catch { loadError = AidenBotMemoryCopy.couldNotLoad + return false } } + /// Saves from the edit sheet, which closes at once: a failure shows on the page. + func saveInBackground(target: AidenBotMemoryTarget, entryId: String, text: String) { + guard let edit = try? AidenBotMemoryEdit.replacing(target: target, entryId: entryId, text: text) else { + errorMessage = AidenBotMemoryCopy.notSaved + return + } + Task { if let failure = await apply(edit) { errorMessage = failure } } + } + /// Replaces one entry's text. Returns nil when it was saved, or the /// person-facing reason it was not (the view is refetched either way on /// failure, so a stale entry disappears or shows its newer text). @@ -154,8 +173,23 @@ final class AidenBotMemoryModel { return true } + /// Shows the edit at once, then sends it after the edits before it. private func apply(_ edit: AidenBotMemoryEdit) async -> String? { - guard !isMutating else { return AidenBotMemoryCopy.notSaved } + let before = memory + if let optimistic = memory?.applying(edit) { memory = optimistic } + editsInFlight += 1 + defer { editsInFlight -= 1 } + let previous = lane + let job = Task { @MainActor [weak self] () -> String? in + await previous?.value + guard let self else { return AidenBotMemoryCopy.notSaved } + return await self.send(edit, restoring: before) + } + lane = Task { _ = await job.value } + return await job.value + } + + private func send(_ edit: AidenBotMemoryEdit, restoring before: AidenBotMemory?) async -> String? { let key: UUID if let retainedEdit, retainedEdit.edit == edit { key = retainedEdit.key @@ -163,23 +197,24 @@ final class AidenBotMemoryModel { key = UUID() retainedEdit = (edit, key) } - isMutating = true - defer { isMutating = false } do { - let updated = try await transport.editBotMemory( - botId: botID, - request: AidenBotMemoryEditRequest(edit: edit), - idempotencyKey: key - ) + let updated = try await aidenBotWithin(.seconds(12)) { [transport, botID] in + try await transport.editBotMemory( + botId: botID, + request: AidenBotMemoryEditRequest(edit: edit), + idempotencyKey: key + ) + } retainedEdit = nil if updated.botId == botID { memory = updated } return nil - } catch is CancellationError { - return AidenBotMemoryCopy.notSaved } catch { if !aidenBotSessionFailureIsAmbiguous(error) { retainedEdit = nil } - let message = message(for: AidenBotMemoryEditFailure(error), edit: edit) - await load() + let message = error is CancellationError + ? AidenBotMemoryCopy.notSaved + : message(for: AidenBotMemoryEditFailure(error), edit: edit) + // The Mac's view is the truth; without it, what showed before the edit. + if await !load() { memory = before } return message } } @@ -278,7 +313,7 @@ struct AidenBotMemoryView: View { Button(AidenBotMemoryCopy.eraseMemory, role: .destructive) { isConfirmingErase = true } - .disabled(!canWrite || model.isMutating) + .disabled(!canWrite) } } .scrollContentBackground(.hidden) @@ -291,10 +326,7 @@ struct AidenBotMemoryView: View { } .background(palette.canvas.ignoresSafeArea()) } else { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(palette.canvas.ignoresSafeArea()) - .accessibilityLabel("Loading memory") + AidenBotMemorySkeletonView() } } @@ -366,6 +398,28 @@ struct AidenBotMemoryView: View { } } +/// Skeleton rows while Memory loads (R15): its shape, never a spinner. +private struct AidenBotMemorySkeletonView: View { + @Environment(\.aidenPalette) private var palette + @Environment(\.aidenReduceMotion) private var reduceMotion + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + AidenBotSkeletonBlock(width: nil, height: 44, radius: 12, reduceMotion: reduceMotion) + AidenBotSkeletonBlock(width: 120, height: 14, radius: 7, reduceMotion: reduceMotion) + AidenBotSkeletonBlock(width: nil, height: 104, radius: 18, reduceMotion: reduceMotion) + AidenBotSkeletonBlock(width: 140, height: 14, radius: 7, reduceMotion: reduceMotion) + AidenBotSkeletonBlock(width: nil, height: 64, radius: 18, reduceMotion: reduceMotion) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(palette.canvas.ignoresSafeArea()) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Loading memory") + } +} + /// A slim neutral meter and its "563 of 1,375 characters" line. private struct AidenBotMemoryUsageMeter: View { let group: AidenBotMemoryGroup @@ -403,8 +457,6 @@ private struct AidenBotMemoryEditSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.aidenPalette) private var palette @State private var text: String - @State private var isSaving = false - @State private var errorMessage: String? init(model: AidenBotMemoryModel, target: AidenBotMemoryTarget, entry: AidenBotMemoryEntry) { self.model = model @@ -416,7 +468,7 @@ private struct AidenBotMemoryEditSheet: View { private var trimmed: String { text.trimmingCharacters(in: .whitespacesAndNewlines) } private var count: Int { trimmed.unicodeScalars.count } private var canSave: Bool { - !isSaving && !trimmed.isEmpty && count <= AidenBotMemoryWire.maxEntryLength && trimmed != entry.text + !trimmed.isEmpty && count <= AidenBotMemoryWire.maxEntryLength && trimmed != entry.text } var body: some View { @@ -432,14 +484,6 @@ private struct AidenBotMemoryEditSheet: View { .font(.caption.monospacedDigit()) .foregroundStyle(count > AidenBotMemoryWire.maxEntryLength ? palette.danger : palette.secondary) .frame(maxWidth: .infinity, alignment: .trailing) - if let errorMessage { - Label(errorMessage, systemImage: "exclamationmark.circle") - .font(.footnote) - .foregroundStyle(palette.danger) - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(palette.danger.opacity(0.1), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - } Spacer(minLength: 0) } .padding(16) @@ -451,17 +495,10 @@ private struct AidenBotMemoryEditSheet: View { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { + // The change shows at once; a failure comes back on the Memory page (R15). Button(AidenBotMemoryCopy.save) { - Task { - isSaving = true - let failure = await model.save(target: target, entryId: entry.id, text: text) - isSaving = false - if let failure { - errorMessage = failure - } else { - dismiss() - } - } + model.saveInBackground(target: target, entryId: entry.id, text: text) + dismiss() } .disabled(!canSave) } diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotProfileView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotProfileView.swift index f14840448..68d23f04e 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotProfileView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotProfileView.swift @@ -19,6 +19,93 @@ private struct AidenBotProfileSkeletonView: View { } } +/// Saves Profile's identity edits (R15). Each edit already shows on the page; it +/// goes to the Mac after the ones before it, on the newest revision, waiting +/// briefly for a connection. A lost answer is settled by re-reading the Bot, so +/// a retry never repeats a change the Mac saved; anything else hands back the +/// Mac's Bot (when it could be read) for the page to roll back to, with an error. +@MainActor +final class AidenBotIdentitySaver { + enum Outcome: Equatable { + case saved(AidenBotDetail) + case failed(authoritative: AidenBotDetail?) + } + + static let failedMessage = "That change wasn’t saved. Please try again." + + private(set) var inFlight = 0 + private var revision: String? + private var lane: Task? + private let send: AidenBotMainActorCall<@MainActor (AidenBotIdentityPatch, String) async throws -> AidenBotDetail> + private let refresh: AidenBotMainActorCall<@MainActor () async throws -> AidenBotDetail> + private let waitForConnection: (@MainActor (Duration) async -> Bool)? + private let connectionHold: Duration + private let requestLimit: Duration + + init( + send: @escaping @MainActor (AidenBotIdentityPatch, String) async throws -> AidenBotDetail, + refresh: @escaping @MainActor () async throws -> AidenBotDetail, + waitForConnection: (@MainActor (Duration) async -> Bool)? = nil, + connectionHold: Duration = .seconds(8), + requestLimit: Duration = .seconds(12) + ) { + self.send = AidenBotMainActorCall(send) + self.refresh = AidenBotMainActorCall(refresh) + self.waitForConnection = waitForConnection + self.connectionHold = connectionHold + self.requestLimit = requestLimit + } + + /// The Bot as last read. A read never moves the revision under a save in flight. + func adopt(_ detail: AidenBotDetail) { + if inFlight == 0 { revision = detail.revision } + } + + func save(_ patch: AidenBotIdentityPatch) async -> Outcome { + inFlight += 1 + defer { inFlight -= 1 } + let previous = lane + let job = Task { @MainActor [weak self] () -> Outcome in + await previous?.value + guard let self else { return .failed(authoritative: nil) } + return await self.run(patch) + } + lane = Task { _ = await job.value } + return await job.value + } + + private func run(_ patch: AidenBotIdentityPatch) async -> Outcome { + do { + if let waitForConnection, await waitForConnection(connectionHold) == false { + throw URLError(.notConnectedToInternet) + } + guard let revision else { throw URLError(.resourceUnavailable) } + let send = send + let detail = try await aidenBotWithin(requestLimit) { try await send.call(patch, revision) } + self.revision = detail.revision + return .saved(detail) + } catch { + let refresh = refresh + guard let fresh = try? await aidenBotWithin(requestLimit, { try await refresh.call() }) else { + return .failed(authoritative: nil) + } + revision = fresh.revision + return patch.isSatisfied(by: fresh) ? .saved(fresh) : .failed(authoritative: fresh) + } + } +} + +extension AidenBotIdentityPatch { + /// The Bot already holds every field this patch sets. + func isSatisfied(by detail: AidenBotDetail) -> Bool { + (name == nil || name == detail.name) + && (purpose == nil || purpose == detail.purpose) + && (instructions == nil || instructions == detail.instructions) + && (avatar == nil || avatar == detail.avatar.semantic) + && (chatActivity == nil || chatActivity == detail.chatActivity) + } +} + private enum AidenBotProfileField: Hashable { case name case subtitle @@ -46,7 +133,7 @@ struct AidenBotProfileView: View { @State private var isLoading = true @State private var loadError: String? @State private var mutationError: String? - @State private var isSaving = false + @State private var saver: AidenBotIdentitySaver? @State private var isDeleting = false @State private var loadGeneration: UInt = 0 @State private var nameText = "" @@ -71,11 +158,12 @@ struct AidenBotProfileView: View { AidenBotCustomAccessSessionIdentity(coordinator: coordinator) } + /// Edits apply at once, cached or fresh, connected or not (R15); each waits + /// briefly for the Mac and rolls back with an error if it doesn't reach it. private var canWrite: Bool { capturedContext.map(coordinator.isCurrent) == true - && coordinator.connectionState == .connected + && saver != nil && coordinator.installationStore.activeInstallation?.canWriteBots == true - && !isSaving && !isDeleting } @@ -91,7 +179,7 @@ struct AidenBotProfileView: View { if showsDismissButton { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } - .disabled(isSaving || isDeleting) + .disabled(isDeleting) } } if detail != nil { @@ -101,10 +189,15 @@ struct AidenBotProfileView: View { } } .navigationDestination(isPresented: $isShowingAdvanced) { - AidenBotAdvancedView(coordinator: coordinator, botID: botID) { updated in - apply(updated) - onChanged() - } + AidenBotAdvancedView( + coordinator: coordinator, + botID: botID, + onSaved: { updated in + apply(updated) + onChanged() + }, + onSaveFailed: { mutationError = $0 } + ) } .navigationDestination(isPresented: $isShowingMemory) { if let memoryModel { @@ -112,16 +205,22 @@ struct AidenBotProfileView: View { } } } - .interactiveDismissDisabled(isSaving || isDeleting || avatarModel?.isBusy == true) - .task(id: sessionIdentity) { + .interactiveDismissDisabled(isDeleting || avatarModel?.isBusy == true) + // A new pairing starts over; a connection coming up only re-reads, keeping edits. + .task(id: sessionIdentity.pairing) { let expectedSession = sessionIdentity reset(for: expectedSession) await load(for: expectedSession) } + .onChange(of: coordinator.connectionState == .connected) { _, connected in + guard connected, detail != nil else { return } + let expectedSession = sessionIdentity + Task { await load(for: expectedSession) } + } .task(id: avatarModel?.sessionIdentity) { await avatarModel?.sessionDidChangeAndRefresh() } - .onChange(of: sessionIdentity) { oldValue, newValue in + .onChange(of: sessionIdentity.pairing) { oldValue, newValue in if showsDismissButton, capturedContext != nil, oldValue != newValue { dismiss() } } .onChange(of: photoItem) { _, item in @@ -301,7 +400,10 @@ struct AidenBotProfileView: View { photoMenu(detail) } if avatarModel?.isBusy == true { - ProgressView() + // The photo is on its way: a shimmer, never a spinner (R15). + AidenBotSkeletonBlock(width: 112, height: 6, radius: 3, reduceMotion: reduceMotion) + .accessibilityElement() + .accessibilityLabel("Saving photo") } if avatarModel?.hasCandidate == true { HStack(spacing: 12) { @@ -385,7 +487,7 @@ struct AidenBotProfileView: View { private func instructionsRow(_ detail: AidenBotDetail) -> some View { NavigationLink { AidenBotInstructionsEditorView(initialText: detail.instructions, canSave: canWrite) { text in - await saveInstructions(text) + startInstructionsSave(text) } } label: { HStack(spacing: 12) { @@ -418,7 +520,7 @@ struct AidenBotProfileView: View { isLoading = true loadError = nil mutationError = nil - isSaving = false + saver = nil character = nil memoryModel = nil isShowingMemory = false @@ -428,6 +530,7 @@ struct AidenBotProfileView: View { @MainActor private func apply(_ loaded: AidenBotDetail) { + saver?.adopt(loaded) detail = loaded if focusedField != .name { nameText = loaded.name } if focusedField != .subtitle { subtitleText = loaded.purpose } @@ -450,9 +553,25 @@ struct AidenBotProfileView: View { await model.load() } + /// The saver for this pairing, made once the page has a Bot to edit. + @MainActor + private func prepareSaver(context: AidenRemoteRequestContext) { + guard saver == nil else { return } + let botID = botID + saver = AidenBotIdentitySaver( + send: { patch, revision in + try await coordinator.remoteClient(for: context).updateBotIdentity(id: botID, revision: revision, patch: patch) + }, + refresh: { try await coordinator.remoteClient(for: context).bot(id: botID) }, + waitForConnection: { hold in await aidenBotWaitForConnection(coordinator, hold: hold) } + ) + if let detail { saver?.adopt(detail) } + } + @MainActor private func load(for expectedSession: AidenBotCustomAccessSessionIdentity) async { - guard sessionIdentity == expectedSession, !isSaving else { return } + // An edit on its way keeps what the page shows until the Mac answers it. + guard sessionIdentity.pairing == expectedSession.pairing, saver?.inFlight ?? 0 == 0 else { return } loadGeneration &+= 1 let generation = loadGeneration isLoading = true @@ -466,13 +585,17 @@ struct AidenBotProfileView: View { let cachedDetail = cached.details.first(where: { $0.id == botID }), isCurrentLoad(generation, session: expectedSession, context: context) { apply(cachedDetail) + // The saved details are editable at once; edits wait for the Mac (R15). + capturedContext = context + prepareSaver(context: context) isLoading = false } let loaded = try await coordinator.remoteClient(for: context).bot(id: botID) guard isCurrentLoad(generation, session: expectedSession, context: context), - loaded.id == botID, !Task.isCancelled else { return } + loaded.id == botID, !Task.isCancelled, saver?.inFlight ?? 0 == 0 else { return } apply(loaded) capturedContext = context + prepareSaver(context: context) loadError = nil if opensAdvanced, !hasOpenedAdvanced { hasOpenedAdvanced = true @@ -502,8 +625,9 @@ struct AidenBotProfileView: View { } catch { if let context = requestContext, await coordinator.handleCredentialRevocation(error, context: context) { return } - guard loadGeneration == generation, sessionIdentity == expectedSession else { return } - capturedContext = nil + guard loadGeneration == generation, sessionIdentity.pairing == expectedSession.pairing else { return } + // Saved details stay editable offline; with none, there is nothing to edit. + if detail == nil { capturedContext = nil } loadError = detail == nil ? error.localizedDescription : "Offline — showing saved details" isLoading = false } @@ -515,7 +639,7 @@ struct AidenBotProfileView: View { session: AidenBotCustomAccessSessionIdentity, context: AidenRemoteRequestContext ) -> Bool { - loadGeneration == generation && sessionIdentity == session && coordinator.isCurrent(context) + loadGeneration == generation && sessionIdentity.pairing == session.pairing && coordinator.isCurrent(context) } // MARK: Saving @@ -543,41 +667,32 @@ struct AidenBotProfileView: View { @MainActor private func saveCharacter(_ next: AidenBotCharacterDraft) async { guard let detail, let patch = try? next.identityPatch(comparedTo: detail) else { return } - let previous = character + // Shown at once; a failed save puts back the Mac's character. character = next - if await !saveIdentity(patch) { character = previous } + _ = await saveIdentity(patch) } + /// The editor closes at once and the change goes out (R15). Returns why it + /// can't be saved, for the editor to show, or nil once it is on its way. @MainActor - private func saveInstructions(_ text: String) async -> Bool { - guard let detail else { return false } + private func startInstructionsSave(_ text: String) -> String? { + guard let detail else { return AidenBotIdentitySaver.failedMessage } let next = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard next != detail.instructions else { return true } - guard !next.isEmpty else { - mutationError = "Instructions can’t be empty." - return false - } - guard let patch = try? AidenBotIdentityPatch(instructions: next) else { - mutationError = "Those instructions are too long." - return false - } - return await saveIdentity(patch) + guard next != detail.instructions else { return nil } + guard !next.isEmpty else { return "Instructions can’t be empty." } + guard let patch = try? AidenBotIdentityPatch(instructions: next) else { return "Those instructions are too long." } + Task { _ = await saveIdentity(patch) } + return nil } - /// Sends one identity change. A lost response is reconciled by re-reading - /// the Bot, so a retry never repeats a change the Mac already saved. + /// Sends one identity change the page already shows. On failure the page + /// rolls back to the Mac's Bot, or to what it showed before, with an error. @MainActor private func saveIdentity(_ patch: AidenBotIdentityPatch) async -> Bool { guard canWrite, let context = capturedContext, coordinator.isCurrent(context), - let detail else { return false } - isSaving = true - defer { isSaving = false } - do { - let updated = try await coordinator.remoteClient(for: context).updateBotIdentity( - id: botID, - revision: detail.revision, - patch: patch - ) + let saver, let before = detail else { return false } + switch await saver.save(patch) { + case let .saved(updated): guard coordinator.isCurrent(context), updated.id == botID else { return false } apply(updated) _ = await coordinator.withRetainedInstallationData(for: context) { @@ -589,16 +704,14 @@ struct AidenBotProfileView: View { } onChanged() return true - } catch is CancellationError { - return false - } catch { - if await coordinator.handleCredentialRevocation(error, context: context) { return false } + case let .failed(authoritative): guard coordinator.isCurrent(context) else { return false } - if let authoritative = try? await coordinator.remoteClient(for: context).bot(id: botID), - coordinator.isCurrent(context) { - apply(authoritative) - } - mutationError = "That change wasn’t saved. Please try again." + let restored = authoritative ?? before + detail = restored + if focusedField != .name { nameText = restored.name } + if focusedField != .subtitle { subtitleText = restored.purpose } + character = AidenBotCharacterDraft(avatar: restored.avatar.semantic) + mutationError = AidenBotIdentitySaver.failedMessage return false } } @@ -636,14 +749,15 @@ struct AidenBotProfileView: View { struct AidenBotInstructionsEditorView: View { let initialText: String let canSave: Bool - let onSave: (String) async -> Bool + /// Starts the save and returns nil, or says why it can't be saved. + let onSave: (String) -> String? @Environment(\.dismiss) private var dismiss @Environment(\.aidenPalette) private var palette @State private var text: String - @State private var isSaving = false + @State private var problem: String? - init(initialText: String, canSave: Bool, onSave: @escaping (String) async -> Bool) { + init(initialText: String, canSave: Bool, onSave: @escaping (String) -> String?) { self.initialText = initialText self.canSave = canSave self.onSave = onSave @@ -651,34 +765,42 @@ struct AidenBotInstructionsEditorView: View { } var body: some View { - TextEditor(text: $text) - .scrollContentBackground(.hidden) - .padding(12) - .frame(minHeight: 220, alignment: .top) - .background(palette.raised, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) - .padding(.horizontal, 16) - .padding(.top, 8) - .frame(maxHeight: .infinity, alignment: .top) - .background(palette.canvas.ignoresSafeArea()) - .navigationTitle("Instructions") - .navigationBarTitleDisplayMode(.inline) - .accessibilityLabel("Instructions") - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(isSaving ? "Saving…" : "Save") { - Task { - isSaving = true - let saved = await onSave(text) - isSaving = false - if saved { dismiss() } - } + VStack(alignment: .leading, spacing: 10) { + TextEditor(text: $text) + .scrollContentBackground(.hidden) + .padding(12) + .frame(minHeight: 220, alignment: .top) + .background(palette.raised, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + .accessibilityLabel("Instructions") + if let problem { + Label(problem, systemImage: "exclamationmark.circle") + .font(.footnote) + .foregroundStyle(palette.danger) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(maxHeight: .infinity, alignment: .top) + .background(palette.canvas.ignoresSafeArea()) + .navigationTitle("Instructions") + .navigationBarTitleDisplayMode(.inline) + .onChange(of: text) { _, _ in problem = nil } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + // Saves as it closes; a failure comes back on the Profile (R15). + Button("Save") { + if let reason = onSave(text) { + problem = reason + } else { + dismiss() } - .buttonStyle(.borderedProminent) - .buttonBorderShape(.capsule) - .tint(palette.accent) - .foregroundStyle(palette.onAccent) - .disabled(!canSave || isSaving || text == initialText) } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .tint(palette.accent) + .foregroundStyle(palette.onAccent) + .disabled(!canSave || text == initialText) } + } } } diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift index fa2f05a65..f93b4d945 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift @@ -79,11 +79,55 @@ extension AidenBotAvatarShape { } } +/// The canonical face's glance while its Bot works: the eyes look left and +/// right and blink, on the shared `glance` keyframes. `dx` is a fraction of the +/// avatar's size; `blink` scales the eyes' height. +struct AidenBotFaceGlancePose: Equatable { + var dx: Double + var blink: Double + + static let still = AidenBotFaceGlancePose(dx: 0, blink: 1) +} + +private let aidenBotGlanceSpec = AidenActivityMarkSpec.make(.glance) + +/// How a working Bot's avatar says so (ruling 8, R19). +struct AidenBotWorkingCue: Equatable { + /// The canonical face's eyes glance. + let glances: Bool + /// A small glance mark sits on the avatar's corner. + let badge: Bool + + static let none = AidenBotWorkingCue(glances: false, badge: false) +} + +/// A canonical face glances while its Bot works. A photo can't, and a face holds +/// still under Reduce Motion or Low Power, so both wear the glance badge (whose +/// mark then holds its still pose): a working Bot always keeps a visible cue. +func aidenBotWorkingCue(working: Bool, hasPhoto: Bool, motionAllowed: Bool) -> AidenBotWorkingCue { + guard working else { return .none } + if !hasPhoto, motionAllowed { return AidenBotWorkingCue(glances: true, badge: false) } + return AidenBotWorkingCue(glances: false, badge: true) +} + +func aidenBotFaceGlancePose(at time: TimeInterval) -> AidenBotFaceGlancePose { + let look = AidenActivityMarkEvaluator.pose(of: aidenBotGlanceSpec.groups[0].tracks, atTime: time) + let eye = AidenActivityMarkEvaluator.pose(of: aidenBotGlanceSpec.shapes[0].tracks, atTime: time) + // The mark's view box is 24 wide. + return AidenBotFaceGlancePose(dx: look.translateX / 24, blink: eye.scaleY) +} + struct AidenBotSemanticAvatarView: View { let avatar: AidenBotSemanticAvatar let name: String let size: CGFloat var isDecorative = true + /// The Bot is in a running turn: its eyes glance (spec §12, ruling 8). + var working = false + + @Environment(\.accessibilityReduceMotion) private var systemReduceMotion + @Environment(\.aidenReduceMotion) private var appReduceMotion + @Environment(\.aidenLowPowerMode) private var lowPowerMode private var presentation: AidenBotAvatarPresentation { aidenBotAvatarPresentation(avatar) @@ -93,7 +137,7 @@ struct AidenBotSemanticAvatarView: View { ZStack { AidenBotAvatarShapeMask(shape: presentation.shape) .fill(presentation.color.swatch) - AidenBotEyeMark(size: size) + AidenBotEyeMark(size: size, glancing: working && !systemReduceMotion && !appReduceMotion && !lowPowerMode) } .frame(width: size, height: size) .contentShape(AidenBotAvatarShapeMask(shape: presentation.shape)) @@ -116,17 +160,28 @@ struct AidenBotShapeSwatch: View { } /// The one eye mark every Bot shares: two short, slightly tilted strokes. +/// While its Bot works they glance; otherwise (and under Reduce Motion) they hold still. private struct AidenBotEyeMark: View { let size: CGFloat + var glancing = false var body: some View { - HStack(spacing: size * 0.09) { - Capsule().frame(width: size * 0.09, height: size * 0.2) - Capsule().frame(width: size * 0.09, height: size * 0.2) + // 30 frames a second is plenty for a glance and spares the battery. + TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !glancing)) { context in + let pose = glancing ? aidenBotFaceGlancePose(at: context.date.timeIntervalSinceReferenceDate) : .still + HStack(spacing: size * 0.09) { + Capsule() + .frame(width: size * 0.09, height: size * 0.2) + .scaleEffect(x: 1, y: pose.blink) + Capsule() + .frame(width: size * 0.09, height: size * 0.2) + .scaleEffect(x: 1, y: pose.blink) + } + .foregroundStyle(Color(aidenRGB: aidenBotAvatarFaceRGB)) + .offset(x: pose.dx * size) + .rotationEffect(.degrees(-12)) + .offset(x: size * 0.08, y: -size * 0.02) } - .foregroundStyle(Color(aidenRGB: aidenBotAvatarFaceRGB)) - .rotationEffect(.degrees(-12)) - .offset(x: size * 0.08, y: -size * 0.02) .accessibilityHidden(true) } } diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift index d0f8e2d4f..c7403381d 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift @@ -20,17 +20,36 @@ struct AidenBotSessionChatView: View { @State private var isConfirmingDelete = false @State private var isDeleting = false @State private var deleteErrorMessage: String? + /// An Advanced Save that didn't reach the Mac after Advanced closed (R15). + @State private var advancedSaveError: String? /// The Bot's conversation tools, loaded once it has a conversation; Files /// is offered only when they grant file access. @State private var filesTools: AidenBotChatToolsModel? @State private var presentedFilesGrant: AidenBotConversationFileGrant? + /// Steer or Queue while the Bot works; back to Queue when the turn ends. + @State private var busyMode = aidenBotBusyMode(nil, isRunning: false) + /// Bumped by Try again after the chat couldn't start, to start it again. + @State private var startAttempt = 0 + /// The chat couldn't start (no pairing or credential to reach the Mac with). + @State private var startFailed = false + /// The transcript is at or near its end, so new rows scroll into view. + @State private var followsBottom = true + /// Bumped by the person's own send: the transcript scrolls to it wherever it was. + @State private var ownSendScroll = 0 + @ScaledMetric(relativeTo: .body) private var controlSize: CGFloat = 40 + @Environment(\.dynamicTypeSize) private var dynamicTypeSize @FocusState private var composerFocused: Bool private var bot: AidenBotSummary? { summary ?? initialSummary } private var canWrite: Bool { - coordinator.connectionState == .connected - && coordinator.installationStore.activeInstallation?.canWriteBots == true + coordinator.connectionState == .connected && canCompose + } + + /// Messages are taken while the Mac is still connecting: they wait in the + /// outbox and go out on connect (R14). + private var canCompose: Bool { + coordinator.installationStore.activeInstallation?.canWriteBots == true } /// Answering a Bot's tool approval needs `approval:respond` beside Bot write. @@ -50,8 +69,13 @@ struct AidenBotSessionChatView: View { var body: some View { VStack(spacing: 0) { transcript - if let model, model.hasLoaded { + // The composer is there at once; messages wait for the Mac (R14). + if let model { footer(model) + } else if startFailed { + startFailureHint + .padding(.horizontal, 16) + .padding(.vertical, 10) } } .background(palette.canvas.ignoresSafeArea()) @@ -97,33 +121,64 @@ struct AidenBotSessionChatView: View { .navigationDestination(isPresented: $isShowingAdvanced) { // "Set up" and "Advanced" land here; a saved model or access // change refreshes the session state. - AidenBotAdvancedView(coordinator: coordinator, botID: botID) { updated in - summary = AidenBotSummary(detail: updated) - Task { await model?.load() } - } + AidenBotAdvancedView( + coordinator: coordinator, + botID: botID, + onSaved: { updated in + summary = AidenBotSummary(detail: updated) + model?.wording = updated.chatActivity?.wording ?? .follows + Task { await model?.load() } + }, + onSaveFailed: { advancedSaveError = $0 } + ) } .alert( "Something Went Wrong", isPresented: Binding( - get: { deleteErrorMessage != nil || (model?.errorMessage != nil && model?.hasLoaded == true) }, + get: { alertMessage != nil }, set: { if !$0 { clearErrors() } } ) ) { Button("OK", role: .cancel) { clearErrors() } } message: { - Text(deleteErrorMessage ?? model?.errorMessage ?? "Please try again.") + Text(alertMessage ?? "Please try again.") } - .task(id: coordinator.activeInstanceId) { + .task(id: "\(coordinator.activeInstanceId ?? "")|\(startAttempt)") { await start() } .task(id: filesLookupKey) { await loadFilesTools() } + // Chat activity is changed in Advanced; reread the Bot when it closes. + .onChange(of: isShowingAdvanced) { _, showing in + if !showing { Task { await loadSummary() } } + } + .onChange(of: model?.isRunning == true) { _, running in + busyMode = aidenBotBusyMode(busyMode, isRunning: running) + } + // The last-known capabilities stand in until the Mac answers (R21). + .onChange(of: aidenBotActivitySupported(coordinator: coordinator)) { _, supported in + model?.activitySupported = supported + } + // A first load that failed is a quiet hint while the chat keeps + // reconnecting; once a snapshot arrives it is no longer news. + .onChange(of: model?.hasLoaded == true) { _, loaded in + if loaded { model?.errorMessage = nil } + } + } + + /// A failed delete, then unsent messages that couldn't be restored (shown + /// even before the first snapshot), then the chat's own error once loaded. + private var alertMessage: String? { + deleteErrorMessage ?? advancedSaveError ?? model?.restoreNotice ?? (model?.hasLoaded == true ? model?.errorMessage : nil) } private func clearErrors() { deleteErrorMessage = nil - model?.errorMessage = nil + advancedSaveError = nil + model?.restoreNotice = nil + // Before the first snapshot the chat's error is the connection hint, not this alert. + if model?.hasLoaded == true { model?.errorMessage = nil } } private var filesGrant: AidenBotConversationFileGrant? { @@ -239,12 +294,17 @@ struct AidenBotSessionChatView: View { .accessibilityHint("Opens the Bot’s profile") } + /// Never a spinner or a blocking error (R14): the last transcript, or + /// skeleton bubbles, with the person's own messages drawn at once. @ViewBuilder private var transcript: some View { - if let model, model.hasLoaded { + if let model { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 12) { + if model.showsSkeleton { + AidenBotSessionSkeletonBubbles(still: isUnreachable) + } ForEach(AidenBotTranscriptRow.rows(for: model.entries)) { row in rowView(row, model: model) .id(row.id) @@ -253,30 +313,78 @@ struct AidenBotSessionChatView: View { assistantBubble(partial, interrupted: false) .id("partial") } + ForEach(model.queued, id: \.submissionId) { message in + queuedBubble(message) + } + ForEach(model.outgoing) { message in + outgoingBubble(message, model: model) + } + AidenBotStatusLineView(model: model) Color.clear.frame(height: 1).id("bottom") } .padding(.horizontal, 16) .padding(.vertical, 12) } .scrollDismissesKeyboard(.interactively) - .onChange(of: model.entries.count) { _, _ in proxy.scrollTo("bottom", anchor: .bottom) } - .onChange(of: model.partial) { _, _ in proxy.scrollTo("bottom", anchor: .bottom) } + // New rows follow only a reader already at the end; one reading back stays put. + .onScrollGeometryChange(for: Bool.self) { geometry in + aidenBotFollowsTranscriptEnd( + distanceFromEnd: geometry.contentSize.height - geometry.contentOffset.y - geometry.containerSize.height + ) + } action: { _, atEnd in + followsBottom = atEnd + } + .onChange(of: model.entries.count) { _, _ in followIfAtEnd(proxy) } + .onChange(of: model.partial) { _, _ in followIfAtEnd(proxy) } + .onChange(of: model.queued.count) { _, _ in followIfAtEnd(proxy) } + .onChange(of: model.outgoing.count) { _, _ in followIfAtEnd(proxy) } + .onChange(of: ownSendScroll) { _, _ in proxy.scrollTo("bottom", anchor: .bottom) } .onAppear { proxy.scrollTo("bottom", anchor: .bottom) } } - } else if let message = model?.errorMessage { - ContentUnavailableView { - Label("Couldn’t Load Chat", systemImage: "exclamationmark.bubble") - } description: { - Text(message) - } actions: { - Button("Try Again") { Task { await model?.load() } } - } - .frame(maxHeight: .infinity) } else { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .accessibilityLabel("Loading chat") + ScrollView { + AidenBotSessionSkeletonBubbles(still: isUnreachable) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .scrollDisabled(true) + } + } + + private func followIfAtEnd(_ proxy: ScrollViewProxy) { + if followsBottom { proxy.scrollTo("bottom", anchor: .bottom) } + } + + /// The Mac can't be reached right now: skeletons hold still instead of shimmering. + private var isUnreachable: Bool { + if startFailed { return true } + if case .offline = coordinator.connectionState { return true } + guard let model else { return false } + if case .unreachable = aidenBotConnectionHint(for: model, nowMs: Int(Date().timeIntervalSince1970 * 1_000)) { + return true + } + return false + } + + /// The chat couldn't start: a quiet hint with Try again, never an endless skeleton. + private var startFailureHint: some View { + HStack(spacing: 6) { + Text(AidenBotSessionCopy.unreachable) + .lineLimit(2) + Button(AidenBotSessionCopy.tryAgain) { + startFailed = false + startAttempt += 1 + } + .font(.footnote.weight(.semibold)) + .foregroundStyle(palette.accent) + .frame(minHeight: 44) + .contentShape(Rectangle()) + .buttonStyle(.plain) } + .font(.footnote) + .foregroundStyle(palette.secondary) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) } @ViewBuilder @@ -333,7 +441,7 @@ struct AidenBotSessionChatView: View { .frame(maxWidth: .infinity) case let .failedTurn(turn): AidenBotFailedTurnCard( - canRetry: model.retryableFailedTurn?.id == turn.id && canWrite && model.canSend, + canRetry: model.retryableFailedTurn?.id == turn.id && canWrite && model.canSend && model.hasLoaded, isBusy: model.inFlight.contains(.retry), onRetry: { Task { await model.retry() } } ) @@ -352,6 +460,92 @@ struct AidenBotSessionChatView: View { } } + /// A user bubble the Mac's transcript doesn't hold yet: a Queued message, + /// or one of the person's own on its way. + private func pendingBubble( + _ text: String, + faded: Bool, + @ViewBuilder caption: () -> Caption + ) -> some View { + VStack(alignment: .trailing, spacing: 4) { + // Waiting reads in the quieter text colour, which keeps 4.5:1 on the bubble. + Text(text) + .foregroundStyle(faded ? Color(aidenHex: aidenBotPendingTextHex(palette)) : palette.foreground) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(palette.raised, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + caption() + .font(.caption) + .foregroundStyle(palette.secondary) + } + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.leading, 48) + } + + /// A message the Mac holds until the reply finishes (Queue) or has already + /// joined the reply (Steer). + private func queuedBubble(_ message: AidenBotQueuedMessage) -> some View { + queuedLook(message.text, mode: message.mode) + } + + private func queuedLook(_ text: String, mode: AidenStreamInputMode) -> some View { + // A queued message waits for the reply; a steer has already joined it. + pendingBubble(text, faded: mode == .queue) { + if mode == .queue { + Text(AidenBotSessionCopy.queued) + } + } + .accessibilityElement(children: .combine) + .accessibilityHint(mode == .queue ? "Sends when the current reply finishes" : "") + } + + /// The person's own message before the Mac's transcript shows it (R14). + @ViewBuilder + private func outgoingBubble(_ message: AidenBotOutgoingMessage, model: AidenBotSessionModel) -> some View { + switch model.look(of: message) { + case .sending: + pendingBubble(message.text, faded: true) { + Text(AidenBotSessionCopy.sending) + } + .accessibilityElement(children: .combine) + case .sent: + pendingBubble(message.text, faded: false) { EmptyView() } + case let .queued(mode): + queuedLook(message.text, mode: mode) + case .failed: + pendingBubble(message.text, faded: false) { + HStack(spacing: 6) { + Label(AidenBotSessionCopy.couldNotSend, systemImage: "exclamationmark.circle.fill") + .labelStyle(.titleAndIcon) + .foregroundStyle(palette.danger) + Text("·") + .accessibilityHidden(true) + Button { + Task { await model.retrySend(message.id) } + } label: { + Text(AidenBotSessionCopy.tryAgain) + .font(.caption.weight(.semibold)) + .foregroundStyle(palette.accent) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .disabled(!canCompose) + Button { + model.discardSend(message.id) + } label: { + Image(systemName: "xmark") + .font(.caption.weight(.semibold)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel(AidenBotSessionCopy.removeMessage) + } + .buttonStyle(.plain) + } + .accessibilityElement(children: .contain) + } + } + private func assistantBubble(_ text: String, interrupted: Bool) -> some View { AidenMarkdownView(content: text) .foregroundStyle(palette.foreground) @@ -363,6 +557,9 @@ struct AidenBotSessionChatView: View { @ViewBuilder private func footer(_ model: AidenBotSessionModel) -> some View { VStack(spacing: 10) { + if let hint = aidenBotConnectionHint(for: model, nowMs: Int(Date().timeIntervalSince1970 * 1_000)) { + connectionHint(hint, model: model) + } if let question = model.question, !model.needsModel { // The Bot's A–E quick replies. A Bot question has no deadline. AidenQuestionCard( @@ -417,58 +614,168 @@ struct AidenBotSessionChatView: View { .padding(.vertical, 10) } - private func composer(_ model: AidenBotSessionModel) -> some View { - HStack(alignment: .bottom, spacing: 10) { - TextField("Message", text: $draft, axis: .vertical) - .lineLimit(1...6) - .focused($composerFocused) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(palette.raised, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - .disabled(!canWrite) - if model.isRunning { + /// A quiet line above the composer, never a blocking screen (R14). + private func connectionHint(_ hint: AidenBotConnectionHint, model: AidenBotSessionModel) -> some View { + HStack(spacing: 6) { + switch hint { + case .connecting: + Text(AidenBotSessionCopy.connecting) + case let .unreachable(message): + Text(message) + .lineLimit(2) + Button(AidenBotSessionCopy.tryAgain) { Task { await model.load() } } + .font(.footnote.weight(.semibold)) + .foregroundStyle(palette.accent) + .frame(minHeight: 44) + .contentShape(Rectangle()) + .buttonStyle(.plain) + } + } + .font(.footnote) + .foregroundStyle(palette.secondary) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + } + + private var draftIsEmpty: Bool { + draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// The draft clears at once and the bubble shows (R14); a refused message + /// comes back to the composer. + private func submit(_ model: AidenBotSessionModel, whenBusy mode: AidenStreamInputMode?) { + guard !draftIsEmpty else { return } + let text = draft + draft = "" + // The person's own message scrolls into view wherever they were reading. + ownSendScroll += 1 + Task { + if !(await model.send(text, whenBusy: mode)), draft.isEmpty { draft = text } + } + } + + /// Steer or Queue while the Bot works (desktop and Android parity): the mode's + /// name sends in that mode; touch and hold for the menu, which works with an + /// empty draft too, so the mode can be picked before typing. + private func busyPill(_ model: AidenBotSessionModel) -> some View { + let available = canCompose && model.canSend + let enabled = available && !draftIsEmpty + return Menu { + ForEach(AidenStreamInputMode.busyModes, id: \.self) { mode in Button { - Task { await model.stop() } + busyMode = mode } label: { - Image(systemName: "stop.fill") - .font(.body.weight(.semibold)) - .frame(width: 40, height: 40) - .background(palette.raised, in: Circle()) - } - .buttonStyle(.plain) - .disabled(!canWrite || model.inFlight.contains(.stop)) - .accessibilityLabel("Stop") - } else { - Button { - let text = draft - Task { - if await model.send(text) { draft = "" } + if busyMode == mode { + Label { Text(mode.busyLabel); Text(mode.busyDetail) } icon: { Image(systemName: "checkmark") } + } else { + Text(mode.busyLabel) + Text(mode.busyDetail) } - } label: { - Image(systemName: "arrow.up") - .font(.body.weight(.semibold)) - .foregroundStyle(palette.onAccent) - .frame(width: 40, height: 40) - .background(palette.accent, in: Circle()) } - .buttonStyle(.plain) - .disabled(!canWrite || !model.canSend - || draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .accessibilityLabel("Send") + .accessibilityAddTraits(busyMode == mode ? .isSelected : []) + } + } label: { + Text(busyMode.busyLabel) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .frame(minHeight: max(44, controlSize)) + .foregroundStyle(enabled ? palette.onAccent : palette.secondary) + .background(enabled ? palette.accent : palette.raised, in: Capsule()) + } primaryAction: { + submit(model, whenBusy: busyMode) + } + .menuStyle(.button) + .buttonStyle(.plain) + .disabled(!available) + .accessibilityLabel(busyMode.busyActionLabel) + .accessibilityHint("Touch and hold to choose Steer or Queue") + } + + /// At accessibility text sizes the controls sit under a full-width field. + @ViewBuilder + private func composer(_ model: AidenBotSessionModel) -> some View { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .trailing, spacing: 10) { + composerField + HStack(spacing: 10) { composerControls(model) } + } + } else { + HStack(alignment: .bottom, spacing: 10) { + composerField + composerControls(model) + } + } + } + + private var composerField: some View { + TextField("Message", text: $draft, axis: .vertical) + .lineLimit(1...6) + .focused($composerFocused) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(palette.raised, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .disabled(!canCompose) + } + + @ViewBuilder + private func composerControls(_ model: AidenBotSessionModel) -> some View { + if model.isRunning { + if model.offersBusyModes { + busyPill(model) } + Button { + Task { await model.stop() } + } label: { + Image(systemName: "stop.fill") + .font(.body.weight(.semibold)) + .frame(width: max(44, controlSize), height: max(44, controlSize)) + .background(palette.raised, in: Circle()) + } + .buttonStyle(.plain) + .disabled(!canWrite || model.isStopping || model.inFlight.contains(.stop)) + .accessibilityLabel("Stop") + } else { + Button { + submit(model, whenBusy: nil) + } label: { + Image(systemName: "arrow.up") + .font(.body.weight(.semibold)) + .foregroundStyle(palette.onAccent) + .frame(width: max(44, controlSize), height: max(44, controlSize)) + .background(palette.accent, in: Circle()) + } + .buttonStyle(.plain) + .disabled(!canCompose || !model.canSend || draftIsEmpty) + .accessibilityLabel("Send") } } @MainActor private func start() async { guard let context = try? coordinator.requestContext(), - let client = try? coordinator.remoteClient(for: context) else { return } - let model = AidenBotSessionModel(botID: botID, transport: client) + let client = try? coordinator.remoteClient(for: context) else { + // No way to reach the Mac yet: say so, with Try again, instead of an endless skeleton. + if model == nil { startFailed = true } + return + } + startFailed = false + // The chat opens on its last transcript and adopts the messages a + // previous visit or launch left unsent (R14, R17). + let model = AidenBotSessionModel( + botID: botID, + transport: client, + cacheKey: AidenBotSessionKey(instanceId: context.instanceId, deviceId: context.deviceId, botID: botID) + ) + model.activitySupported = aidenBotActivitySupported(coordinator: coordinator) self.model = model + // Nothing waits on another: the first snapshot, the Bot's detail and the + // stream (with the outbox it sends) start together. async let summaryLoad: Void = loadSummary() - await model.load() - _ = await summaryLoad + async let firstLoad: Void = model.load() await model.listen() + // Leaving stops the sending at once; the reads still finishing don't hold it up. + model.release() + _ = await (summaryLoad, firstLoad) } @MainActor @@ -482,6 +789,7 @@ struct AidenBotSessionChatView: View { if let detail = try? await coordinator.remoteClient(for: context).bot(id: botID), coordinator.isCurrent(context) { summary = AidenBotSummary(detail: detail) + model?.wording = detail.chatActivity?.wording ?? .follows } } } @@ -496,6 +804,160 @@ func aidenBotSessionConversationChatID( aidenCanonicalBotConversations(conversations.filter { $0.botId == botID }).first?.chatId } +extension AidenBotSessionCopy { + static let sending = "Sending…" + static let couldNotSend = "Couldn’t send" + static let tryAgain = "Try again" + static let connecting = "Connecting…" + static let removeMessage = "Remove message" + static let unreachable = "Aiden couldn’t reach your Mac. Try again." +} + +/// The Steer/Queue pill's mode: Queue by default, kept while the Bot works, and +/// back to Queue when the turn ends (ruling 6, as in normal chats). +func aidenBotBusyMode(_ current: AidenStreamInputMode?, isRunning: Bool) -> AidenStreamInputMode { + guard isRunning, let current else { return .queue } + return current +} + +/// Whether the transcript is close enough to its end that new rows should follow. +func aidenBotFollowsTranscriptEnd(distanceFromEnd: CGFloat) -> Bool { + distanceFromEnd <= 80 +} + +/// The text of a message still waiting (Sending…, Queued): the palette's quieter +/// text colour, nudged toward the main one only if a theme or contrast setting +/// leaves it under 4.5:1 on the bubble. +func aidenBotPendingTextHex(_ palette: AidenPalette) -> String { + var hex = palette.secondaryHex + var step = 0.0 + while aidenContrastRatio(hex, palette.raisedHex) < 4.5, step < 1 { + step += 0.1 + hex = Color.mixHex(palette.secondaryHex, palette.foregroundHex, fraction: step) + } + return hex +} + +/// WCAG 2 contrast between two `#RRGGBB` colours. +func aidenContrastRatio(_ first: String, _ second: String) -> Double { + func luminance(_ hex: String) -> Double { + let clean = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex + let value = Int(clean, radix: 16) ?? 0 + let channels = [(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF].map { channel -> Double in + let c = Double(channel) / 255 + return c <= 0.03928 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2] + } + let (a, b) = (luminance(first), luminance(second)) + return (max(a, b) + 0.05) / (min(a, b) + 0.05) +} + +/// The quiet hint under a Bot chat while the Mac isn't reachable (R14). +enum AidenBotConnectionHint: Equatable { + /// "Connecting…": no snapshot yet, or the stream dropped. + case connecting + /// The first load failed: its message, with Try again. The chat stays usable + /// and keeps reconnecting. + case unreachable(String) +} + +/// The hint to show, or nil when connected. While a turn runs the status line +/// already says "Reconnecting to your Mac", so the hint stays out of the way. +@MainActor +func aidenBotConnectionHint(for model: AidenBotSessionModel, nowMs: Int) -> AidenBotConnectionHint? { + guard model.isConnecting else { return nil } + if model.statusLine(nowMs: nowMs)?.display.phase == "reconnecting" { return nil } + if !model.hasLoaded, let message = model.errorMessage { return .unreachable(message) } + return .connecting +} + +/// Skeleton bubbles while a chat opens with nothing cached (R14), shaped like +/// the shell's Bot chat skeleton so nothing jumps; never a spinner. +private struct AidenBotSessionSkeletonBubbles: View { + /// The Mac can't be reached: the shimmer stops, so nothing pretends to load. + var still = false + @Environment(\.aidenReduceMotion) private var reduceMotion + + var body: some View { + let quiet = reduceMotion || still + VStack(alignment: .leading, spacing: 14) { + AidenBotSkeletonBlock(width: 206, height: 48, radius: 20, reduceMotion: quiet) + AidenBotSkeletonBlock(width: 252, height: 70, radius: 20, reduceMotion: quiet) + HStack { + Spacer(minLength: 72) + AidenBotSkeletonBlock(width: 164, height: 48, radius: 20, reduceMotion: quiet) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Loading chat") + } +} + +/// The status line while a Bot works (spec §12): a native activity mark and the +/// shared word, plus the elapsed time after 20 seconds. No steps, no Stop. +struct AidenBotStatusLineView: View { + let model: AidenBotSessionModel + + @Environment(\.aidenPalette) private var palette + @State private var announcer = AidenBotStatusAnnouncer() + @ScaledMetric(relativeTo: .subheadline) private var markSize: CGFloat = 18 + + var body: some View { + // Whether the line shows changes with the session, not the clock. + let shown = model.statusLine(nowMs: Int(Date().timeIntervalSince1970 * 1_000)) != nil + Group { + // The clock ticks only while the line can show (FR-m1). + if model.statusLineTicks { + ticking + } + } + .onChange(of: shown) { _, visible in + // A hidden line starts afresh: its next word is announced at once. + if !visible { announcer.reset() } + } + } + + private var ticking: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + if let line = model.statusLine(nowMs: Int(context.date.timeIntervalSince1970 * 1_000)) { + HStack(spacing: 8) { + AidenActivityMark(mark: line.display.mark, size: markSize, color: palette.secondary) + Text(line.display.word) + if let elapsed = line.elapsed { + Text("· \(elapsed)").monospacedDigit() + } + } + .font(.subheadline) + .foregroundStyle(palette.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + // The accessible name is the word; the elapsed time is its value. + .accessibilityElement(children: .ignore) + .accessibilityLabel(line.display.word) + .accessibilityValue(line.elapsed ?? "") + .accessibilityAddTraits(.updatesFrequently) + .onChange(of: line.display.word, initial: true) { _, word in announce(word) } + } + } + } + + /// VoiceOver hears a change at most once every 2 seconds; the newest word wins. + private func announce(_ word: String) { + let offer = announcer.offer(word, at: Date().timeIntervalSinceReferenceDate) + if let text = offer.announce { + AccessibilityNotification.Announcement(text).post() + } else if let retry = offer.retryAfter { + Task { @MainActor in + try? await Task.sleep(for: .seconds(retry)) + if let latest = model.statusLine(nowMs: Int(Date().timeIntervalSince1970 * 1_000))?.display.word { + announce(latest) + } + } + } + } +} + /// A centred, quiet "Memory updated" line. Tapping it opens Profile → Memory. struct AidenBotMemoryUpdatedCaption: View { let onOpen: () -> Void diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index ce4e3ef01..1e1871dcb 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import Observation @@ -12,6 +13,8 @@ enum AidenBotHostFeature { /// Contract revision 27: routine proposal answers, the daily check-in /// suggestion and the Bot routine notification feed. static let proactive = "bot-proactive-v1" + /// Contract revision 30: the status line, Steer and Queue, Queued bubbles and Chat activity. + static let activity = "bot-activity-v1" static func isAdvertised(_ token: String, by features: [String]?) -> Bool { features?.contains(token) == true @@ -23,6 +26,21 @@ enum AidenBotHostFeature { } } +/// Whether the Bot activity features show (revision 30): the Mac announces +/// `bot-activity-v1` and this phone holds `bot:activity`. Until `/server` +/// answers (a cold or offline open), the pairing's last-known feature and +/// grant stand in, so the status line, Steer/Queue and the Chat activity row +/// keep working (R21). Once `/server` answers, it decides. +func aidenBotActivitySupported(server: AidenServer?, installation: AidenInstallation?) -> Bool { + if let server { return server.supportsBotActivity } + return installation?.cachedBotActivity == true +} + +@MainActor +func aidenBotActivitySupported(coordinator: AidenRemoteCoordinator) -> Bool { + aidenBotActivitySupported(server: coordinator.server, installation: coordinator.installationStore.activeInstallation) +} + /// How a Bot chat is opened. A host advertising `bot-durable-session-v1` /// serves one durable session per Bot; older hosts keep the chatId path, /// including stream Stop (`POST /streams/{id}/cancel`). @@ -100,6 +118,9 @@ enum AidenBotSessionCopy { static let needsModel = "Needs an AI model" static let needsModelHint = "Set up on your Mac" static let setUp = "Set up" + static let queued = "Queued" + /// The outbox file a previous launch left couldn't be read; it was set aside, not deleted. + static let outboxUnrestorable = "Some unsent messages couldn’t be restored." static func needsModelDetail(botName: String) -> String { "Set up an AI model so \(botName) can reply." @@ -125,6 +146,666 @@ enum AidenBotSessionCopy { static func proposalAdded(label: String) -> String { "Added ✓ · \(label)" } } +/// The status line's words and marks (spec §5). Kept in step with +/// `renderer/shared/bot-activity.ts` by the shared fixture's `botActivity.phases`. +enum AidenBotActivityPhase { + struct Display: Equatable { + let phase: String + let word: String + let mark: AidenActivityMarkKind + } + + static let table: [String: (word: String, mark: AidenActivityMarkKind)] = [ + "preparing": ("Getting ready", .bounce), + "thinking": ("Thinking", .triStep), + "reading": ("Reading", .scanGrid), + "photo": ("Looking at your photo", .scanGrid), + "searching": ("Searching the web", .scanGrid), + "command": ("Running a command", .quadShuffle), + "editing": ("Editing files", .quadShuffle), + "app": ("Using an app", .quadShuffle), + "helper": ("Asking a helper", .quadShuffle), + "computer": ("Using your Mac", .quadShuffle), + "remembering": ("Remembering", .compose), + "routine": ("Setting up a routine", .triStep), + "working": ("Working", .quadShuffle), + "writing": ("Writing", .compose), + "waiting": ("Waiting for you", .glance), + "tidying": ("Tidying up its notes", .bounce), + "stopping": ("Stopping", .bounce), + "resuming": ("Picking up where it left off", .bounce), + "reconnecting": ("Reconnecting to your Mac", .bounce), + ] + + /// These change what the person should do, so Always "Thinking" keeps them. + static let keepsItsWord: Set = ["waiting", "stopping", "reconnecting"] + + /// What the line shows for any phase id: an id this build doesn't know reads as Working. + static func display(phase: String, wording: AidenBotChatActivityWording, app: String?) -> Display { + let known = table[phase] == nil ? "working" : phase + let shown = wording == .thinking && !keepsItsWord.contains(known) ? "thinking" : known + // `known` and `shown` are both table ids, so the lookup always succeeds. + let entry = table[shown]! + if shown == "app", let app, !app.isEmpty { + return Display(phase: shown, word: "Using \(app)", mark: entry.mark) + } + return Display(phase: shown, word: entry.word, mark: entry.mark) + } +} + +/// Elapsed time on the status line (§5.2): from the Mac's `startedAt`, after 20 seconds. +enum AidenBotElapsed { + static let showAfterMs = 20_000 + + /// Nil before 20 seconds, and while this phone's clock reads behind the Mac's. + static func label(startedAt: Int, nowMs: Int) -> String? { + let elapsed = nowMs - startedAt + guard elapsed >= showAfterMs else { return nil } + return duration(ms: elapsed) + } + + /// "18s", "1m 12s", "2m", "1h 2m", the Mac's wording. + static func duration(ms: Int) -> String { + let seconds = max(1, ms / 1_000) + if seconds < 60 { return "\(seconds)s" } + let minutes = seconds / 60 + let rest = seconds % 60 + if minutes < 60 { return rest > 0 ? "\(minutes)m \(rest)s" : "\(minutes)m" } + let hours = minutes / 60 + let remainder = minutes % 60 + return remainder > 0 ? "\(hours)h \(remainder)m" : "\(hours)h" + } +} + +/// What the Bot chat's status line shows (spec §12). +struct AidenBotStatusLine: Equatable { + let display: AidenBotActivityPhase.Display + /// "1m 12s" after 20 seconds, else nil. + let elapsed: String? + + var accessibilityLabel: String { + elapsed.map { "\(display.word), \($0)" } ?? display.word + } +} + +/// Screen readers hear a status change at most once every 2 seconds (§14). +struct AidenBotStatusAnnouncer { + static let gap: TimeInterval = 2 + private var current: (text: String, at: TimeInterval)? + + /// The line hid: whatever it says next is announced at once. + mutating func reset() { current = nil } + + /// The text to announce now, or nil. `retryAfter` says when to offer the newest text again. + mutating func offer(_ text: String, at now: TimeInterval) -> (announce: String?, retryAfter: TimeInterval?) { + if let current, current.text == text { return (nil, nil) } + if let current, now - current.at < Self.gap { return (nil, Self.gap - (now - current.at)) } + current = (text, now) + return (text, nil) + } +} + +/// Names one Bot chat across model instances and launches: its Mac and its Bot. +/// The pairing's `deviceId` is part of it, so a re-paired phone never sees the +/// old pairing's messages. +struct AidenBotSessionKey: Hashable, Sendable { + let instanceId: String + let deviceId: String + let botID: String +} + +/// A message the person sent that the Mac's transcript doesn't show yet (R14). +/// It shows at once and is replaced by the Mac's own entry or Queued bubble. +struct AidenBotOutgoingMessage: Equatable, Identifiable, Sendable { + enum Status: Equatable, Sendable { + /// Waiting for a connection, or on its way to the Mac. + case sending + /// The Mac took it; its transcript entry or Queued bubble is on the way. + case sent(submissionId: String) + /// It didn't reach the Mac: "Couldn’t send · Try again". + case failed + } + + /// The Idempotency-Key. Every attempt of one message uses it, unless the Mac + /// definitely refused it; then Try again is a new request with a new key. + fileprivate(set) var key: UUID + let text: String + /// Steer or Queue, when it was sent while the Bot worked on a Mac that offers them. + let mode: AidenStreamInputMode? + /// Sent while the Bot worked: it reads as Queued until the turn takes it in. + let enqueuedWhileRunning: Bool + /// The failed turn this message sends again (R18), or nil. + let retryOf: String? + let createdAt: Date + fileprivate(set) var status: Status + + var id: String { "outgoing:\(key.uuidString)" } + + /// The request body of the first attempt; later attempts with the same key resend it unchanged. + fileprivate var request: AidenBotMessageRequest? + /// Entry ids the transcript held at the first attempt: the Mac's echo is a newer entry. + fileprivate var baseline: Set = [] + /// The Mac's Queued bubble for it; while that shows, this copy is hidden. + fileprivate var queuedID: String? + /// A transcript entry already stands for it while its request is unsettled; + /// it stays (hidden) until the receipt or failure settles it. + fileprivate var echoed = false + fileprivate var echoEntryID: String? + /// The submission that entry names, if any: only a receipt with that id settles it. + fileprivate var echoSubmissionID: String? + /// Submissions known not to be this message: entries naming them never settle it. + fileprivate var foreignSubmissionIDs: Set = [] + /// Waits for the next connection or backoff before trying again. + fileprivate var deferred = false + fileprivate var connectionAttempts = 0 + /// The last failure may have reached the Mac, so Try again keeps the key. + fileprivate var keepsKey = false + /// When a snapshot first showed nothing on the Mac holding it (FR-m5). The + /// queued list caps at 20 and the Mac leaves out inputs it can't classify, + /// so one snapshot is not proof: it goes only after a grace period. + fileprivate var unheldSince: Date? + + fileprivate var submissionId: String? { + if case let .sent(id) = status { return id } + return nil + } + + /// Too old to send: "Couldn’t send · Try again", keeping the key if the Mac may hold it. + fileprivate mutating func expire() { + status = .failed + keepsKey = request != nil + deferred = false + dropEcho() + } + + /// Forgets the entry it waited on; one that names another submission stays foreign. + fileprivate mutating func dropEcho() { + if let theirs = echoSubmissionID { foreignSubmissionIDs.insert(theirs) } + echoed = false + echoEntryID = nil + echoSubmissionID = nil + } + + /// Whether the Mac may hold it, so its echo can stand in for it. + fileprivate var mayHaveReachedMac: Bool { + guard request != nil else { return false } + if case .failed = status { return keepsKey } + return true + } +} + +/// How the chat draws an outgoing message (R14). +enum AidenBotOutgoingLook: Equatable { + /// Faded, "Sending…". + case sending + /// The Mac took it; its own entry replaces it shortly. + case sent + /// The Bot is working: Queued, as Steer or Queue. + case queued(AidenStreamInputMode) + /// "Couldn’t send · Try again". + case failed +} + +/// What each Bot chat last showed in this run of the app, so a chat opens on +/// its messages instead of waiting for the Mac, and its unsent messages outlive +/// leaving the chat (R14, R17). Memory only; `AidenBotOutboxStore` keeps the +/// outbox across launches. +@MainActor +final class AidenBotSessionCache { + static let shared = AidenBotSessionCache() + static let capacity = 16 + + private var transcripts: [AidenBotSessionKey: [AidenBotSessionEntry]] = [:] + private var order: [AidenBotSessionKey] = [] + private var outboxes: [AidenBotSessionKey: [AidenBotOutgoingMessage]] = [:] + /// The model that writes each chat's outbox; an older one left open never overwrites it. + private var owners: [AidenBotSessionKey: ObjectIdentifier] = [:] + + func transcript(for key: AidenBotSessionKey) -> [AidenBotSessionEntry]? { transcripts[key] } + + func store(_ transcript: [AidenBotSessionEntry], for key: AidenBotSessionKey) { + transcripts[key] = transcript + order.removeAll { $0 == key } + order.append(key) + while order.count > Self.capacity { transcripts[order.removeFirst()] = nil } + } + + /// The chat's unsent messages, now written by `owner` only. + func adoptOutbox(for key: AidenBotSessionKey, owner: ObjectIdentifier) -> [AidenBotOutgoingMessage] { + owners[key] = owner + return outboxes[key] ?? [] + } + + /// Stores the outbox when `owner` writes this chat; false when another model does. + @discardableResult + func storeOutbox(_ outbox: [AidenBotOutgoingMessage], for key: AidenBotSessionKey, owner: ObjectIdentifier) -> Bool { + guard owners[key] == nil || owners[key] == owner else { return false } + owners[key] = owner + outboxes[key] = outbox.isEmpty ? nil : outbox + return true + } + + /// Access to this chat ended (401/403, accessChanged). + func purge(_ key: AidenBotSessionKey) { + transcripts[key] = nil + outboxes[key] = nil + order.removeAll { $0 == key } + } + + /// This Mac was unpaired. + func purge(instanceId: String) { + for key in Set(transcripts.keys).union(outboxes.keys) where key.instanceId == instanceId { purge(key) } + owners = owners.filter { $0.key.instanceId != instanceId } + } +} + +/// The unsent messages of each Bot chat on disk, so they survive a relaunch +/// (R17): protected until first unlock, excluded from backup, keyed by the +/// pairing, and purged with it. A message still unsent after 24 hours comes +/// back as "Couldn’t send · Try again", never silently gone. +actor AidenBotOutboxStore { + static let shared = AidenBotOutboxStore() + static let lifetime: TimeInterval = 24 * 60 * 60 + /// The most messages one chat may hold waiting; another is refused, visibly. + static let maxMessages = 20 + /// One chat's file. 20 of the largest messages fit: 32,000 code points each, + /// stored twice (text and request), at up to 6 JSON bytes per code point. + static let maxBytes = 10_000_000 + + /// A write handed to the store: the newest one per chat replaces one not yet made. + enum Write: Sendable { + case save([AidenBotOutgoingMessage]) + case remove + } + + /// Writes waiting per chat, filled from any thread. + private final class Pending: @unchecked Sendable { + private let lock = NSLock() + private var writes: [AidenBotSessionKey: Write] = [:] + /// Every pairing a write was handed in for, per Mac, made or not. + private var pairings: [String: Set] = [:] + + func put(_ write: Write, for key: AidenBotSessionKey) { + lock.withLock { + writes[key] = write + pairings[key.instanceId, default: []].insert(key.deviceId) + } + } + + /// The waiting write, if any. A pairing with nothing left waiting is forgotten + /// here; the store's own record of what it wrote covers it from now on. + func take(_ key: AidenBotSessionKey) -> Write? { + lock.withLock { + let write = writes.removeValue(forKey: key) + if !writes.keys.contains(where: { $0.instanceId == key.instanceId && $0.deviceId == key.deviceId }) { + pairings[key.instanceId]?.remove(key.deviceId) + if pairings[key.instanceId]?.isEmpty == true { pairings[key.instanceId] = nil } + } + return write + } + } + + /// Drops this Mac's waiting writes; returns the pairings they were handed in for. + func drop(instanceId: String) -> Set { + lock.withLock { + writes = writes.filter { $0.key.instanceId != instanceId } + return pairings.removeValue(forKey: instanceId) ?? [] + } + } + } + + private let pending = Pending() + /// Pairings seen per Mac, and those an unpair purged: a late write never recreates their files. + private var knownDevices: [String: Set] = [:] + private var revokedDevices: Set = [] + /// Chats whose unreadable file couldn't be moved aside: it is never written over. + private var stuckFiles: Set = [] + + private struct Record: Codable { + let key: UUID + let text: String + let mode: AidenStreamInputMode? + let enqueuedWhileRunning: Bool + let retryOf: String? + let createdAt: Date + let submissionId: String? + let failed: Bool + let request: AidenBotMessageRequest? + let baseline: [String] + let connectionAttempts: Int + let keepsKey: Bool + /// Absent in records written before fix round 4. + let foreignSubmissionIDs: [String]? + } + + /// One record decoded on its own, so a bad one costs only itself. + private struct Lossy: Decodable { + let record: Record? + init(from decoder: Decoder) throws { record = try? Record(from: decoder) } + } + + private struct Envelope: Encodable { + let version: Int + let instanceId: String + let deviceId: String + let botID: String + let messages: [Record] + } + + private struct ReadEnvelope: Decodable { + let version: Int + let instanceId: String + let deviceId: String + let botID: String + let messages: [Lossy] + } + + private let root: URL + private let fileManager: FileManager + private let now: @Sendable () -> Date + + init(root: URL? = nil, now: @escaping @Sendable () -> Date = { Date() }, fileManager: FileManager = .default) { + self.fileManager = fileManager + self.now = now + if let root { + self.root = root + } else { + let applicationSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + self.root = applicationSupport + .appending(path: "AidenOnTheGo", directoryHint: .isDirectory) + .appending(path: "BotOutbox-v1", directoryHint: .isDirectory) + } + } + + /// The chat's messages, oldest first, and whether some couldn't be read back. + /// One still unsent after 24 hours comes back as not sent; a taken one that + /// old is the Mac's now. A file this build can't read (too large, damaged, + /// or a newer version) is set aside beside it, never deleted, and the chat + /// starts a new one. + func restore(_ key: AidenBotSessionKey) async -> (messages: [AidenBotOutgoingMessage], unreadable: Bool) { + let restored = readFile(key) + await afterRead?() + return restored + } + + /// Tests only: awaited by `restore` once it has read the file, before it hands the messages back. + private var afterRead: (@Sendable () async -> Void)? + + func setAfterRead(_ hook: (@Sendable () async -> Void)?) { afterRead = hook } + + private func readFile(_ key: AidenBotSessionKey) -> (messages: [AidenBotOutgoingMessage], unreadable: Bool) { + knownDevices[key.instanceId, default: []].insert(key.deviceId) + let url = fileURL(key) + let data: Data + do { + data = try Data(contentsOf: url) + } catch CocoaError.fileReadNoSuchFile { + return ([], false) + } catch { + // There, but it can't be opened: it is set aside unread, never written over. + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .degraded, code: .corruptData) + setAside(url, for: key, keepingOriginal: false) + return ([], true) + } + guard data.count <= 2 * Self.maxBytes else { + // Never written this large. + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .degraded, code: .corruptData) + setAside(url, for: key, keepingOriginal: false) + return ([], true) + } + guard let envelope = try? JSONDecoder().decode(ReadEnvelope.self, from: data), + envelope.version == 1, envelope.instanceId == key.instanceId, + envelope.deviceId == key.deviceId, envelope.botID == key.botID else { + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .degraded, code: .corruptData) + setAside(url, for: key, keepingOriginal: false) + return ([], true) + } + let readable = envelope.messages.compactMap(\.record) + let lostSome = readable.count != envelope.messages.count + if lostSome { + // The readable ones are written back; the file as it was is kept beside them. + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .degraded, code: .corruptData) + setAside(url, for: key, keepingOriginal: true) + } + let cutoff = now().addingTimeInterval(-Self.lifetime) + var changed = readable.count != envelope.messages.count + var messages: [AidenBotOutgoingMessage] = [] + for record in readable { + let expired = record.createdAt <= cutoff + if expired, record.submissionId != nil { + changed = true + continue + } + var message = AidenBotOutgoingMessage( + key: record.key, + text: record.text, + mode: record.mode, + enqueuedWhileRunning: record.enqueuedWhileRunning, + retryOf: record.retryOf, + createdAt: record.createdAt, + status: record.failed ? .failed : record.submissionId.map { .sent(submissionId: $0) } ?? .sending + ) + message.request = record.request + message.baseline = Set(record.baseline) + message.connectionAttempts = record.connectionAttempts + message.keepsKey = record.keepsKey + message.foreignSubmissionIDs = Set(record.foreignSubmissionIDs ?? []) + if expired, message.status == .sending { + message.expire() + changed = true + } + messages.append(message) + } + if changed { save(messages, for: key) } + return (messages, lostSome) + } + + /// Moves (or copies) a file this build can't fully read out of the chat's way. + /// If it can't be moved, the chat never writes over it. + private func setAside(_ url: URL, for key: AidenBotSessionKey, keepingOriginal: Bool) { + let aside = url.deletingPathExtension().appendingPathExtension("\(UUID().uuidString).unreadable") + do { + if keepingOriginal { + try fileManager.copyItem(at: url, to: aside) + } else { + try fileManager.moveItem(at: url, to: aside) + } + } catch { + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .failed, code: .unknown) + if !keepingOriginal { stuckFiles.insert(key) } + } + } + + /// Hands a write to the store's own writer, which makes it in order with + /// the other writes for this chat and with an unpair's purge. The store + /// holds it until it is made, whatever happens to the model that sent it. + nonisolated func submit(_ write: Write, for key: AidenBotSessionKey) { + pending.put(write, for: key) + Task { await self.drain(key) } + } + + private func drain(_ key: AidenBotSessionKey) { + guard let write = pending.take(key) else { return } + knownDevices[key.instanceId, default: []].insert(key.deviceId) + switch write { + case .remove: + remove(key) + case let .save(messages): + guard !revokedDevices.contains(Self.pairing(key)) else { return } + save(messages, for: key) + } + } + + private static func pairing(_ key: AidenBotSessionKey) -> String { "\(key.instanceId)\u{1f}\(key.deviceId)" } + + private func save(_ messages: [AidenBotOutgoingMessage], for key: AidenBotSessionKey) { + knownDevices[key.instanceId, default: []].insert(key.deviceId) + let records = messages.map { message in + Record( + key: message.key, + text: message.text, + mode: message.mode, + enqueuedWhileRunning: message.enqueuedWhileRunning, + retryOf: message.retryOf, + createdAt: message.createdAt, + submissionId: message.submissionId, + failed: message.status == .failed, + request: message.request, + baseline: Array(message.baseline), + connectionAttempts: message.connectionAttempts, + keepsKey: message.keepsKey, + // The entry it waited on goes with it too, as adopting it in memory does. + foreignSubmissionIDs: message.foreignSubmissionIDs.union(message.echoSubmissionID.map { [$0] } ?? []).sorted() + ) + } + guard !stuckFiles.contains(key) else { + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .failed, code: .unknown) + return + } + if records.isEmpty { + remove(key) + return + } + do { + try write(records, for: key) + } catch { + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .failed, code: .unknown) + } + } + + private func remove(_ key: AidenBotSessionKey) { + try? fileManager.removeItem(at: fileURL(key)) + } + + /// This Mac was unpaired: its files go, writes still waiting are dropped, + /// and its pairings are never written again. + func purge(instanceId: String) { + // Pairings with a write still waiting count too, though nothing of theirs is on disk yet. + let waiting = pending.drop(instanceId: instanceId) + for device in (knownDevices[instanceId] ?? []).union(waiting) { + revokedDevices.insert(Self.pairing(AidenBotSessionKey(instanceId: instanceId, deviceId: device, botID: ""))) + } + stuckFiles = stuckFiles.filter { $0.instanceId != instanceId } + try? fileManager.removeItem(at: root.appending(path: digest(instanceId), directoryHint: .isDirectory)) + } + + private func write(_ records: [Record], for key: AidenBotSessionKey) throws { + let data = try JSONEncoder().encode(Envelope( + version: 1, instanceId: key.instanceId, deviceId: key.deviceId, botID: key.botID, messages: records + )) + guard data.count <= Self.maxBytes else { + // The chat refuses messages past this budget, so this keeps the last good file. + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .failed, code: .unknown) + return + } + try writeProtected(data, to: fileURL(key)) + } + + /// Protected until first unlock, and never in a backup: what waits to reach the Mac stays on this device. + private func writeProtected(_ data: Data, to url: URL) throws { + let protection: [FileAttributeKey: Any] = [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] + try fileManager.createDirectory(at: root, withIntermediateDirectories: true, attributes: protection) + var excluded = URLResourceValues() + excluded.isExcludedFromBackup = true + var backupRoot = root + try? backupRoot.setResourceValues(excluded) + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: protection) + try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + private func fileURL(_ key: AidenBotSessionKey) -> URL { + pairingDirectory(instanceId: key.instanceId, deviceId: key.deviceId) + .appending(path: "\(digest(key.botID)).json") + } + + private func pairingDirectory(instanceId: String, deviceId: String) -> URL { + root.appending(path: digest(instanceId), directoryHint: .isDirectory) + .appending(path: digest(deviceId), directoryHint: .isDirectory) + } + + // MARK: A New Bot on its way (R15) + + /// The pairing's one New Bot on its way, beside its chats' outboxes, so it has their + /// protection and goes with an unpair's purge. A chat file is named by a 64-character digest, + /// so this name never meets one. + private func pendingCreateURL(instanceId: String, deviceId: String) -> URL { + pairingDirectory(instanceId: instanceId, deviceId: deviceId).appending(path: "create.json") + } + + private struct CreateEnvelope: Codable { + let version: Int + let instanceId: String + let deviceId: String + let create: AidenBotPendingCreate + } + + /// The New Bot an earlier launch left on its way for this pairing, if any. Past + /// `AidenBotPendingCreate.replayWindow` its keys are no longer safe to send: one whose Bot the + /// Mac made is the Mac's and is dropped; one that may have reached the Mac comes back (and stays + /// on disk) with its answers and what it sent, but without its keys; one never sent stays as it + /// is. Its answers are never silently gone. A file this build can't read is set aside. + func pendingCreate(instanceId: String, deviceId: String) -> AidenBotPendingCreate? { + knownDevices[instanceId, default: []].insert(deviceId) + let url = pendingCreateURL(instanceId: instanceId, deviceId: deviceId) + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + return nil + } + // The Bot cache's date rules, so a made Bot reads back as the Mac sent it. + guard let envelope = try? JSONDecoder.aidenRemote().decode(CreateEnvelope.self, from: data), envelope.version == 1, + envelope.instanceId == instanceId, envelope.deviceId == deviceId else { + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .degraded, code: .corruptData) + let aside = url.deletingPathExtension().appendingPathExtension("\(UUID().uuidString).unreadable") + if (try? fileManager.moveItem(at: url, to: aside)) == nil { + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .failed, code: .unknown) + } + return nil + } + let create = envelope.create + guard let frozen = create.frozen, !AidenBotPendingCreate.canReplay(sentAt: create.createdAt, now: now()) else { + return create + } + guard frozen.created == nil else { + try? fileManager.removeItem(at: url) + return nil + } + let lapsed = AidenBotPendingCreate(input: create.input, frozen: nil, createdAt: create.createdAt, lapsed: frozen.request) + savePendingCreate(lapsed, instanceId: instanceId, deviceId: deviceId) + return lapsed + } + + /// Keeps the pairing's New Bot on its way; never for a pairing an unpair purged. Returns + /// whether it is on disk: a create that isn't must not go out. + @discardableResult + func savePendingCreate(_ create: AidenBotPendingCreate, instanceId: String, deviceId: String) -> Bool { + knownDevices[instanceId, default: []].insert(deviceId) + guard !revokedDevices.contains(Self.pairing(AidenBotSessionKey(instanceId: instanceId, deviceId: deviceId, botID: ""))) else { + return false + } + do { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(CreateEnvelope(version: 1, instanceId: instanceId, deviceId: deviceId, create: create)) + try writeProtected(data, to: pendingCreateURL(instanceId: instanceId, deviceId: deviceId)) + return true + } catch { + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .failed, code: .unknown) + return false + } + } + + func removePendingCreate(instanceId: String, deviceId: String) { + try? fileManager.removeItem(at: pendingCreateURL(instanceId: instanceId, deviceId: deviceId)) + } + + private func digest(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } +} + /// One row of the durable Bot transcript. Consecutive `memory_update` entries /// collapse into one quiet "Memory updated" caption. enum AidenBotTranscriptRow: Equatable, Identifiable { @@ -160,6 +841,87 @@ func aidenBotSessionFailureIsAmbiguous(_ error: Error) -> Bool { /// One Bot's durable conversation: a snapshot from `GET /session`, kept /// current by the session event stream and its `(epoch, seq)` rule. +/// A Bot request that didn't answer in time: it may still have reached the Mac, +/// so it is ambiguous and keeps its Idempotency-Key. +struct AidenBotRequestTimedOut: LocalizedError { + var errorDescription: String? { "Your Mac didn’t answer in time." } +} + +/// Runs `operation`, giving up after `limit`. The phone's URLSession waits for +/// connectivity for minutes; a chat action must not. The caller goes on time +/// even if the request ignores cancellation, and cancelling the caller +/// cancels the request. +func aidenBotWithin( + _ limit: Duration, + _ operation: @escaping @Sendable () async throws -> T +) async throws -> T { + let race = AidenBotRequestRace() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + race.start(continuation, limit: limit, operation: operation) + } + } onCancel: { + race.cancel() + } +} + +/// The first of the request, its time limit and the caller's cancellation wins. +private final class AidenBotRequestRace: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var work: Task? + private var timer: Task? + private var cancelled = false + + /// The request and its timer are made in the same step as the cancelled + /// check, so a cancel either comes first (nothing starts) or finds them. + func start(_ continuation: CheckedContinuation, limit: Duration, operation: @escaping @Sendable () async throws -> T) { + let alreadyCancelled = lock.withLock { () -> Bool in + if cancelled { return true } + self.continuation = continuation + let work = Task { [weak self] in + // Cancelled before it began: the request never goes out. + guard !Task.isCancelled else { return } + do { + let value = try await operation() + self?.finish(.success(value)) + } catch { + self?.finish(.failure(error)) + } + } + self.work = work + self.timer = Task { [weak self] in + try? await Task.sleep(for: limit) + guard !Task.isCancelled else { return } + work.cancel() + self?.finish(.failure(AidenBotRequestTimedOut())) + } + return false + } + if alreadyCancelled { continuation.resume(throwing: CancellationError()) } + } + + func cancel() { + let tasks = lock.withLock { () -> (Task?, Task?) in + cancelled = true + return (work, timer) + } + tasks.0?.cancel() + tasks.1?.cancel() + finish(.failure(CancellationError())) + } + + private func finish(_ result: Result) { + let (waiting, timer) = lock.withLock { () -> (CheckedContinuation?, Task?) in + defer { continuation = nil } + return (continuation, self.timer) + } + guard let waiting else { return } + timer?.cancel() + waiting.resume(with: result) + } +} + @MainActor @Observable final class AidenBotSessionModel { @@ -184,21 +946,122 @@ final class AidenBotSessionModel { private(set) var sentConnectionRequests: Set = [] /// Routine proposals with an answer on its way to the Mac, by proposal id. private(set) var respondingProposals: Set = [] + /// Revision 30: the Mac announces `bot-activity-v1` and this phone holds `bot:activity`. + var activitySupported = false + /// The Bot's Chat activity wording, from its detail. + var wording: AidenBotChatActivityWording = .follows + /// What the running turn is doing (revision 30), or nil. + private(set) var activity: AidenBotSessionActivity? + /// The person's messages waiting for the running turn (revision 30). + private(set) var queued: [AidenBotQueuedMessage] = [] + /// The session stream dropped and is reconnecting. + private(set) var isStreamDown = false + /// Messages sent from this phone the Mac's transcript doesn't show yet, oldest first. + private(set) var outbox: [AidenBotOutgoingMessage] = [] { + didSet { if outbox != oldValue { outboxDidChange() } } + } + /// Stop was pressed: the line says "Stopping" until the Mac speaks or the turn ends. + private var stopIntent: Intent = .none + /// Resume was pressed: the line says "Picking up where it left off" until the Mac speaks (R18). + private var resumeIntent: Intent = .none var errorMessage: String? + /// Unsent messages a previous launch left couldn't be read back. Shown until dismissed. + var restoreNotice: String? @ObservationIgnored private var retainedKeys: [Action: UUID] = [:] - @ObservationIgnored private var retainedMessage: (text: String, key: UUID)? + @ObservationIgnored private let cache: AidenBotSessionCache + @ObservationIgnored private let cacheKey: AidenBotSessionKey? + @ObservationIgnored private let outboxStore: AidenBotOutboxStore? + @ObservationIgnored private let retryDelays: [Duration] @ObservationIgnored private var retainedQuestion: (waitId: String, request: AidenQuestionRespondRequest, key: UUID)? @ObservationIgnored private var retainedApproval: (waitId: String, decision: AidenApprovalDecision, key: UUID)? @ObservationIgnored private var connectionKeys: [String: UUID] = [:] - /// The key of a Retry that failed ambiguously, by failed-turn id. - @ObservationIgnored private var retainedRetry: (turnID: String, key: UUID)? /// The key of a proposal answer that failed ambiguously, by proposal id. @ObservationIgnored private var proposalKeys: [String: (decision: AidenBotRoutineProposalDecision, key: UUID)] = [:] + /// Transcript entries that already stand for an outgoing message, for the life of an epoch: + /// each entry settles one message, once. + @ObservationIgnored private var claimedEntries: Set = [] + @ObservationIgnored private var claimsEpoch: String? + /// The run a Stop was pressed in, by its `startedAt`. + @ObservationIgnored private var stopStartedAt: Int? + /// The one task that sends the outbox; `release()` cancels it. + @ObservationIgnored private var flushTask: Task? + @ObservationIgnored private var backoffTask: Task? + @ObservationIgnored private var restoreTask: Task? + /// The previous launch's file has been read and merged; disk writes wait for it. + @ObservationIgnored private var outboxRestored = false + /// Bumped by each access-loss purge: a restore that started before one drops what it read. + @ObservationIgnored private var purgeGeneration = 0 + @ObservationIgnored private var released = false + @ObservationIgnored private let requestLimit: Duration + @ObservationIgnored private let now: () -> Date + /// How many times the Mac has written each field, so a failed action rolls + /// back only what nothing newer replaced (R18). + @ObservationIgnored private var stateWrites = 0 + @ObservationIgnored private var questionWrites = 0 + @ObservationIgnored private var approvalWrites = 0 + + private enum Intent { case none, requested, acknowledged } + + /// Tries a message gets on a connection that keeps failing before it says "Couldn’t send". + static let maxConnectionAttempts = 3 + /// How long a taken message a snapshot didn't show stays before it goes (FR-m5). + static let unheldGrace: TimeInterval = 10 - init(botID: String, transport: any AidenBotSessionTransport) { + /// `cacheKey` names this chat across model instances and launches (its Mac + /// and Bot). With one, the chat opens on its last transcript and adopts the + /// unsent messages a previous visit or launch left (R14, R17). + /// `retryDelays` space the retries of a message whose request may not have + /// arrived while the Mac is connected. + init( + botID: String, + transport: any AidenBotSessionTransport, + cacheKey: AidenBotSessionKey? = nil, + cache: AidenBotSessionCache = .shared, + outboxStore: AidenBotOutboxStore? = .shared, + retryDelays: [Duration] = [.seconds(1), .seconds(4)], + requestLimit: Duration = .seconds(12), + now: @escaping () -> Date = { Date() } + ) { self.botID = botID self.transport = transport + self.cacheKey = cacheKey + self.cache = cache + self.outboxStore = cacheKey == nil ? nil : outboxStore + self.retryDelays = retryDelays + self.requestLimit = requestLimit + self.now = now + if let cacheKey { + entries = cache.transcript(for: cacheKey) ?? [] + let now = now() + outbox = cache.adoptOutbox(for: cacheKey, owner: ObjectIdentifier(self)).map { Self.adopted($0, now: now) } + } + } + + /// A message another visit left keeps its key and body; what it saw then is + /// stale, and one still unsent after 24 hours says it couldn't be sent. + private static func adopted(_ message: AidenBotOutgoingMessage, now: Date) -> AidenBotOutgoingMessage { + var message = message + message.deferred = false + message.dropEcho() + message.queuedID = nil + message.unheldSince = nil + if message.status == .sending, isExpired(message, now: now) { message.expire() } + return message + } + + private static func isExpired(_ message: AidenBotOutgoingMessage, now: Date) -> Bool { + now.timeIntervalSince(message.createdAt) >= AidenBotOutboxStore.lifetime + } + + /// The chat closed: sending and retry timers stop at once, nothing more is + /// written, and the unsent messages stay for the next visit. The store's own + /// writer finishes a write already handed to it, so the next launch sees + /// the latest outbox. + func release() { + released = true + backoffTask?.cancel() + flushTask?.cancel() } var state: AidenBotSessionStateKind? { stateView?.state } @@ -206,44 +1069,139 @@ final class AidenBotSessionModel { var isRunning: Bool { state == .running } var isInterrupted: Bool { stateView?.interrupted == true } var isAccessBlocked: Bool { stateView?.blocked == .accessChanged } - var canSend: Bool { hasLoaded && !needsModel && state != .unavailable && !inFlight.contains(.send) } + /// The composer takes a message unless the Mac said this Bot can't take one. + /// Before the first snapshot it waits in the outbox (R14). + var canSend: Bool { !needsModel && state != .unavailable } + + /// No snapshot yet: the chat draws skeleton bubbles where it has no transcript (R14). + var showsSkeleton: Bool { !hasLoaded && entries.isEmpty } + /// Not connected yet, or reconnecting: a quiet "Connecting…" hint, never a blocking screen. + var isConnecting: Bool { !hasLoaded || isStreamDown } + /// Connected well enough to send now; otherwise messages wait in the outbox. + private var isConnected: Bool { hasLoaded && !isStreamDown } + + /// The outgoing messages to draw after the transcript and the Mac's Queued bubbles. + var outgoing: [AidenBotOutgoingMessage] { outbox.filter { !$0.echoed && !isShownAsQueued($0) } } + + private func isShownAsQueued(_ message: AidenBotOutgoingMessage) -> Bool { + guard let id = message.queuedID else { return false } + return queued.contains { $0.submissionId == id } + } + + /// A message was sent and the turn hasn't started on the Mac yet. + var isAwaitingTurn: Bool { outbox.contains { $0.status == .sending } } + + /// Stop was pressed and the turn is still running. + var isStopping: Bool { stopIntent != .none && isRunning } + /// Resume was pressed and the Mac hasn't said what the resumed run is doing yet. + var isResuming: Bool { resumeIntent != .none && isRunning } + + func look(of message: AidenBotOutgoingMessage) -> AidenBotOutgoingLook { + switch message.status { + case .failed: + return .failed + case .sent where message.queuedID != nil: + // It left the Mac's queue: the turn took it in, and its entry is on the way. + return .sent + case _ where activitySupported && isRunning && message.enqueuedWhileRunning: + return .queued(message.mode ?? .queue) + case .sending: + return .sending + case .sent: + return .sent + } + } + + /// Steer and Queue while the Bot works need the Mac's `bot-activity-v1`. + var offersBusyModes: Bool { activitySupported && isRunning } + + /// Phases with no tool running: while the reply's words stream in them, the + /// words are the signal (§5.2), as on the Mac. + private static let replyPhases: Set = ["writing", "thinking"] + + /// The line can show, so its clock ticks (FR-m1): an idle chat runs no timer. + var statusLineTicks: Bool { activitySupported && (isRunning || isAwaitingTurn) } + + /// The status line now (spec §12), or nil when it is hidden. + func statusLine(nowMs: Int) -> AidenBotStatusLine? { + guard statusLineTicks else { return nil } + func line(_ phase: String, elapsed: String? = nil) -> AidenBotStatusLine { + AidenBotStatusLine(display: AidenBotActivityPhase.display(phase: phase, wording: wording, app: nil), elapsed: elapsed) + } + // A message just sent says "Getting ready" at once, before the Mac's state catches up. + guard isRunning else { return line(isStreamDown ? "reconnecting" : "preparing") } + let elapsed = activity.flatMap { AidenBotElapsed.label(startedAt: $0.startedAt, nowMs: nowMs) } + if isStreamDown { return line("reconnecting", elapsed: elapsed) } + if isStopping { return line("stopping", elapsed: elapsed) } + if isResuming { return line("resuming", elapsed: elapsed) } + // The partial text can outlast narration into a tool call, so a running step keeps the line. + let replyStreaming = !(partial ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if replyStreaming, activity.map({ Self.replyPhases.contains($0.phase) }) ?? true { return nil } + // Sent, and the first activity frame is on its way. + guard let activity else { return line("preparing") } + return AidenBotStatusLine( + display: AidenBotActivityPhase.display(phase: activity.phase, wording: wording, app: activity.app), + elapsed: elapsed + ) + } // MARK: Loading and the event stream + /// `GET /session`, after the outbox a previous launch left is restored; + /// returns once what waits in the outbox has gone out. func load() async { + await restoreOutbox() _ = await fetchSnapshot() + await runFlush() } - /// `GET /session`. Returns false when no snapshot was loaded. + /// `GET /session`. Returns false when no snapshot was loaded. A snapshot + /// means the Mac is reachable, so the outbox goes out, without waiting here. private func fetchSnapshot() async -> Bool { do { - apply(snapshot: try await transport.botSession(botId: botID)) + let session = try await aidenBotWithin(requestLimit) { [transport, botID] in + try await transport.botSession(botId: botID) + } + // Cleared first, so what the snapshot itself reports (a purged outbox) stays. errorMessage = nil + apply(snapshot: session) return true } catch is CancellationError { return false } catch { + // Losing access says so once ("That message wasn't sent"); nothing overwrites it. + if Self.isAccessLoss(error) { + purgeForAccessLoss() + if errorMessage != nil { return false } + } errorMessage = hasLoaded ? "Couldn’t refresh this chat." : error.localizedDescription return false } } /// Listens until cancelled, reconnecting after a close or a dropped - /// connection. Each connection starts with a snapshot. + /// connection. Each connection starts with a snapshot. Outbox requests run + /// beside it and never hold up a frame. func listen() async { + await restoreOutbox() var failures = 0 while !Task.isCancelled { do { for try await event in transport.botSessionEvents(botId: botID) { failures = 0 + isStreamDown = false if await apply(event) == .closed { break } } } catch is CancellationError { return } catch { failures += 1 + if Self.isAccessLoss(error) { purgeForAccessLoss() } } guard !Task.isCancelled else { return } + // A dropped connection says "Reconnecting to your Mac" until the next one's first + // frame. A clean close reconnects at once, so it doesn't flash the word. + if failures > 0 { isStreamDown = true } let delay = min(30.0, 0.5 * pow(2.0, Double(min(failures, 6)))) try? await Task.sleep(for: .seconds(failures == 0 ? 0.2 : delay)) } @@ -295,18 +1253,35 @@ final class AidenBotSessionModel { // entries leave it alone; the host clears it with `partial ""`. if case let .message(message) = entry, message.role == .assistant { partial = nil } } + if let cacheKey { cache.store(entries, for: cacheKey) } case .unknownEntry: // A newer Mac's entry type: the sequence advances, nothing renders. break case let .state(view): - stateView = view + setStateView(view) case let .question(next): question = next + questionWrites += 1 case let .approval(next): approval = next + approvalWrites += 1 + case let .activity(next): + activity = next + // The Mac's own word now stands for a Stop or Resume it has heard, + // and a new run (another `startedAt`, or a first activity after a Stop + // pressed before any) has nothing to stop. + if stopIntent == .acknowledged || (next != nil && (stopStartedAt == nil || next?.startedAt != stopStartedAt)) { + stopIntent = .none + } + // Any frame after the tap is the Mac's word on the resumed run. + if next != nil { resumeIntent = .none } + case let .queued(next): + queued = next case .closed: return .closed } + reconcileOutbox(snapshot: false) + scheduleFlush() return .applied } @@ -328,10 +1303,353 @@ final class AidenBotSessionModel { seq = session.seq entries = session.entries partial = session.partial.flatMap { $0.isEmpty ? nil : $0 } - stateView = session.stateView + setStateView(session.stateView) question = session.question approval = session.approval + questionWrites += 1 + approvalWrites += 1 + activity = session.activity + queued = session.queued hasLoaded = true + if let cacheKey { cache.store(entries, for: cacheKey) } + reconcileOutbox(snapshot: true) + // A snapshot is a connection: what waits in the outbox goes out now. + wakeOutbox() + } + + /// A turn that is no longer running takes its Stop and Resume with it, so the + /// next turn never starts at "Stopping". A Bot whose access changed loses + /// what waited to go to it. + private func setStateView(_ view: AidenBotSessionStateView?) { + let wasBlocked = stateView?.blocked == .accessChanged + stateView = view + stateWrites += 1 + if view?.state != .running { + stopIntent = .none + resumeIntent = .none + } + if view?.blocked == .accessChanged, !wasBlocked { purgeForAccessLoss() } + } + + // MARK: Reconciling the outbox + + /// Server truth replaces optimistic messages (R14, R16, R17). A taken message + /// gives way to the entry with its `submissionId`; text stands in only where + /// the entry has none (an older Mac), for a request still unsettled, or for a + /// Queued bubble that beats the receipt. Each entry settles one message for + /// the life of the epoch. An unsettled message is never removed: it hides + /// and settles on its receipt. On a Mac that sends its queue, a taken message + /// that a snapshot shows neither queued nor echoed is marked unheld, and goes + /// once `unheldGrace` has passed with nothing holding it (FR-m5): the queued + /// list caps at 20 and the Mac leaves out what it can't classify, so one + /// snapshot alone never drops it. Seen again, it is held again. + private func reconcileOutbox(snapshot: Bool) { + if claimsEpoch != epoch { + claimsEpoch = epoch + claimedEntries = [] + } + guard !outbox.isEmpty else { return } + let owned = Set(outbox.compactMap(\.submissionId)) + let queuedIDs = Set(queued.map(\.submissionId)) + var claimedQueued = owned + var kept: [AidenBotOutgoingMessage] = [] + for var message in outbox { + guard message.mayHaveReachedMac else { + kept.append(message) + continue + } + if !message.echoed, let echo = echoEntry(for: message, owned: owned) { + claimedEntries.insert(echo.id) + guard message.status == .sending else { continue } + message.echoed = true + message.echoEntryID = echo.id + message.echoSubmissionID = echo.submissionId + } + if !message.echoed { + if let id = message.submissionId { + if queuedIDs.contains(id) { + message.queuedID = id + message.unheldSince = nil + } else if activitySupported { + // Taken, not queued and not in the transcript: nothing on the Mac shows it. + if let since = message.unheldSince { + if now().timeIntervalSince(since) >= Self.unheldGrace { continue } + } else if snapshot { + message.unheldSince = now() + } + } + } else if message.queuedID == nil, + let match = queued.first(where: { !claimedQueued.contains($0.submissionId) && $0.text == message.text }) { + // The Queued frame can beat the receipt. + claimedQueued.insert(match.submissionId) + message.queuedID = match.submissionId + } + } + kept.append(message) + } + if kept != outbox { outbox = kept } + } + + /// The unclaimed user entry that stands for `message`, if any. + private func echoEntry(for message: AidenBotOutgoingMessage, owned: Set) -> AidenBotSessionMessage? { + var byText: AidenBotSessionMessage? + for entry in entries { + guard case let .message(sent) = entry, sent.role == .user, !claimedEntries.contains(sent.id) else { continue } + if let id = message.submissionId, sent.submissionId == id { return sent } + guard byText == nil, !message.baseline.contains(sent.id), + sent.text.trimmingCharacters(in: .whitespacesAndNewlines) == message.text else { continue } + if let theirs = sent.submissionId { + // An entry that names its submission belongs to that submission. Only a request + // still unsettled may wait on one (its receipt says whose it is); a failed one + // never takes it. + if message.submissionId != nil || message.status == .failed || owned.contains(theirs) + || message.foreignSubmissionIDs.contains(theirs) { + continue + } + } + byText = sent + } + return byText + } + + // MARK: The outbox + + /// Writes the outbox to the cache and disk while this model owns the chat; + /// an older model left open never overwrites a newer one's messages. The + /// store keeps one ordered writer per chat. + private func outboxDidChange() { + guard let cacheKey, !released, + cache.storeOutbox(outbox, for: cacheKey, owner: ObjectIdentifier(self)) else { return } + // A write before the restore would replace the previous launch's file. The first change starts the + // restore, which writes the merged outbox once. + guard outboxRestored else { startRestore(); return } + outboxStore?.submit(.save(outbox), for: cacheKey) + } + + /// The one write the restore owes: the merged outbox, even if the chat closed while the file was read, + /// since messages sent before the restore exist nowhere else. + private func writeRestoredOutbox(_ cacheKey: AidenBotSessionKey) { + guard let outboxStore, !outbox.isEmpty, + cache.storeOutbox(outbox, for: cacheKey, owner: ObjectIdentifier(self)) else { return } + outboxStore.submit(.save(outbox), for: cacheKey) + } + + /// Adds what a previous launch left on disk, once, before the first snapshot sends it. + func restoreOutbox() async { + guard outboxStore != nil else { return } + startRestore() + await restoreTask?.value + } + + /// Starts the one restore of this chat's file, unless it has started. Until it has merged the file, + /// nothing is written to disk. + private func startRestore() { + guard restoreTask == nil, !outboxRestored, let cacheKey, let outboxStore else { return } + let generation = purgeGeneration + restoreTask = Task { [weak self] in + let (stored, unreadable) = await outboxStore.restore(cacheKey) + // Access was lost while the file was read: what it held stays purged, in memory and on disk. + guard let self, !Task.isCancelled, generation == self.purgeGeneration else { return } + if unreadable { self.restoreNotice = AidenBotSessionCopy.outboxUnrestorable } + let known = Set(self.outbox.map(\.key)) + let now = self.now() + let added = stored.filter { !known.contains($0.key) }.map { Self.adopted($0, now: now) } + if !added.isEmpty { + // Silent until the restore is marked done below, so this merge is written once. + self.outbox = (added + self.outbox).sorted { $0.createdAt < $1.createdAt } + } + self.outboxRestored = true + self.writeRestoredOutbox(cacheKey) + } + } + + /// The Mac is reachable again: deferred messages get another try. + private func wakeOutbox() { + for index in outbox.indices where outbox[index].deferred { outbox[index].deferred = false } + scheduleFlush() + } + + /// Starts the one task that sends the outbox, unless it already runs. + private func scheduleFlush() { + guard !released, flushTask == nil, nextToDeliver() != nil else { return } + flushTask = Task { [weak self] in await self?.flushOutbox() } + } + + /// Sends the outbox and waits for it; cancelling the caller cancels the sending. + private func runFlush() async { + scheduleFlush() + guard let task = flushTask else { return } + await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + } + + /// The oldest unsettled message, unless it waits for a retry: nothing + /// overtakes it, so messages reach the Mac in the order they were sent. + private func nextToDeliver() -> UUID? { + guard isConnected, !released, let head = outbox.first(where: { $0.status == .sending }), !head.deferred else { + return nil + } + return head.key + } + + /// The sending loop of `flushTask`. It clears its handle in the same step as + /// its last check, so a message added later starts a new one. + private func flushOutbox() async { + while let key = nextToDeliver(), !Task.isCancelled { + await deliver(key) + } + flushTask = nil + } + + private func deliver(_ key: UUID) async { + guard let index = outbox.firstIndex(where: { $0.key == key }) else { return } + if needsModel || state == .unavailable { + // A frozen request may have reached the Mac: Try again replays its key. + outbox[index].status = .failed + outbox[index].keepsKey = outbox[index].request != nil + releaseEcho(at: index) + return + } + if Self.isExpired(outbox[index], now: now()) { + // Unsent for a day: it says so instead of going out late. + releaseEcho(at: index) + outbox[index].expire() + return + } + let request: AidenBotMessageRequest + if let frozen = outbox[index].request { + request = frozen + } else { + let whenBusy = offersBusyModes ? outbox[index].mode : nil + guard let made = try? AidenBotMessageRequest(text: outbox[index].text, whenBusy: whenBusy) else { + outbox[index].status = .failed + return + } + request = made + outbox[index].request = made + outbox[index].baseline = Set(entries.map(\.id)) + } + let isRetry = outbox[index].retryOf != nil + let writes = stateWrites + inFlight.insert(.send) + if isRetry { inFlight.insert(.retry) } + defer { + inFlight.remove(.send) + inFlight.remove(.retry) + } + do { + let receipt = try await aidenBotWithin(requestLimit) { [transport, botID] in + try await transport.sendBotMessage(botId: botID, request: request, idempotencyKey: key) + } + if stateWrites == writes { setStateView(receipt.stateView) } + guard let current = outbox.firstIndex(where: { $0.key == key }) else { return } + if outbox[current].echoed, let echoID = outbox[current].echoSubmissionID, echoID != receipt.submissionId { + // The entry it waited on was another submission's: give it back. + releaseEcho(at: current) + } + if outbox[current].echoed { + outbox.remove(at: current) + } else { + outbox[current].status = .sent(submissionId: receipt.submissionId) + outbox[current].deferred = false + outbox[current].keepsKey = false + } + reconcileOutbox(snapshot: false) + } catch { + // A closed chat, or a cancelled send (leaving the chat), leaves the message + // as it was, with no try spent, for the next visit or connection. The + // cancelled request may still have arrived. + guard !released, !(error is CancellationError), !Task.isCancelled, + let current = outbox.firstIndex(where: { $0.key == key }) else { return } + if Self.isAccessLoss(error) { + purgeForAccessLoss() + return + } + guard Self.messageFailureIsAmbiguous(error) else { + // Refused outright (R22): the Mac answered this key without running it, + // and replays that answer for as long as it keeps the key, so Try again + // is a new request with a new key, as on Android. + outbox[current].status = .failed + outbox[current].keepsKey = false + releaseEcho(at: current) + return + } + outbox[current].keepsKey = true + outbox[current].connectionAttempts += 1 + let attempts = outbox[current].connectionAttempts + if attempts < Self.maxConnectionAttempts { + // It keeps its place and key: the next connection, or a short wait while + // connected, tries again. Nothing behind it overtakes it. + outbox[current].deferred = true + scheduleBackoff(after: attempts) + } else if outbox[current].echoed, outbox[current].echoSubmissionID == nil { + // The Mac's transcript already shows it. + outbox.remove(at: current) + } else { + // Out of tries. An entry naming another submission isn't this one: + // it says "Couldn’t send", and Try again keeps the key. + releaseEcho(at: current) + outbox[current].status = .failed + } + } + } + + /// Gives the entry a message waited on back to the other messages. + private func releaseEcho(at index: Int) { + if let entry = outbox[index].echoEntryID { claimedEntries.remove(entry) } + outbox[index].dropEcho() + } + + private func scheduleBackoff(after attempts: Int) { + guard isConnected, !retryDelays.isEmpty else { return } + let delay = retryDelays[min(attempts - 1, retryDelays.count - 1)] + backoffTask?.cancel() + backoffTask = Task { [weak self] in + try? await Task.sleep(for: delay) + guard !Task.isCancelled, let self, !self.released else { return } + self.wakeOutbox() + } + } + + /// Whether a message request may have reached the Mac. A request the Mac is + /// still working on (`409 idempotency_in_flight`) keeps its key too. + private static func messageFailureIsAmbiguous(_ error: Error) -> Bool { + if case let AidenRemoteClientError.server(409, body) = error, body.code.rawValue == "idempotency_in_flight" { + return true + } + return aidenBotSessionFailureIsAmbiguous(error) + } + + /// 401 or 403, or a pairing that's gone: this phone no longer reaches this Bot. + private static func isAccessLoss(_ error: Error) -> Bool { + guard let error = error as? AidenRemoteClientError else { return false } + switch error { + case let .server(status, _), let .unexpectedStatus(status): + return status == 401 || status == 403 + case .missingCredential, .installationChanged: + return true + default: + return false + } + } + + /// Access ended: nothing unsent is kept for this chat, in memory or on disk. + private func purgeForAccessLoss() { + let hadUnsent = outbox.contains { $0.submissionId == nil } + backoffTask?.cancel() + purgeGeneration += 1 + restoreTask?.cancel() + outbox = [] + if let cacheKey { + cache.purge(cacheKey) + outboxStore?.submit(.remove, for: cacheKey) + } + // Nothing is left on disk to wait for: later messages are written as usual. + outboxRestored = true + if hadUnsent { errorMessage = "That message wasn’t sent. Please try again." } } // MARK: Quick-reply questions @@ -340,11 +1658,13 @@ final class AidenBotSessionModel { hasLoaded && question != nil && !needsModel && !inFlight.contains(.answerQuestion) } - /// Answers the waiting question. The request UUID is kept across an ambiguous - /// failure, so retrying the same answer replays the Mac's receipt instead of - /// answering twice; a different answer is a new request. + /// Answers the waiting question. The card goes at once and comes back with an + /// error if the answer didn't reach the Mac (R18). The request UUID is kept + /// across an ambiguous failure, so retrying the same answer replays the Mac's + /// receipt instead of answering twice; a different answer is a new request. func answerQuestion(_ response: AidenQuestionRespondRequest) async { - guard canAnswerQuestion, let waitId = question?.waitId else { return } + guard canAnswerQuestion, let shown = question else { return } + let waitId = shown.waitId let key: UUID if let retainedQuestion, retainedQuestion.waitId == waitId, retainedQuestion.request == response { key = retainedQuestion.key @@ -352,20 +1672,18 @@ final class AidenBotSessionModel { key = UUID() retainedQuestion = (waitId, response, key) } + let mark = questionWrites + question = nil inFlight.insert(.answerQuestion) defer { inFlight.remove(.answerQuestion) } do { - _ = try await transport.answerBotQuestion( - botId: botID, - waitId: waitId, - request: response, - idempotencyKey: key - ) + _ = try await aidenBotWithin(requestLimit) { [transport, botID] in + try await transport.answerBotQuestion(botId: botID, waitId: waitId, request: response, idempotencyKey: key) + } retainedQuestion = nil - if question?.waitId == waitId { question = nil } - } catch is CancellationError { - return } catch { + if questionWrites == mark { question = shown } + if error is CancellationError { return } if !aidenBotSessionFailureIsAmbiguous(error) { retainedQuestion = nil } errorMessage = "That answer wasn’t sent. Please try again." } @@ -377,11 +1695,12 @@ final class AidenBotSessionModel { hasLoaded && approval != nil && !inFlight.contains(.answerApproval) } - /// Allows or denies the waiting tool call, once. Allow is refused when the - /// Mac says the phone may only deny it. The request UUID survives an - /// ambiguous failure so retrying the same decision replays the Mac's - /// receipt; another decision is a new request. When nothing is waiting any - /// more (expired or already answered), the card goes away. + /// Allows or denies the waiting tool call, once. The card goes at once and + /// comes back with an error if the decision didn't reach the Mac (R18). + /// Allow is refused when the Mac says the phone may only deny it. The + /// request UUID survives an ambiguous failure so retrying the same decision + /// replays the Mac's receipt; another decision is a new request. When nothing + /// is waiting any more (expired or already answered), the card stays gone. func answerApproval(_ decision: AidenApprovalDecision) async { guard canAnswerApproval, let current = approval else { return } if decision == .allow && !current.canAllow { return } @@ -393,145 +1712,210 @@ final class AidenBotSessionModel { key = UUID() retainedApproval = (waitId, decision, key) } + let mark = approvalWrites + approval = nil inFlight.insert(.answerApproval) defer { inFlight.remove(.answerApproval) } do { - _ = try await transport.respondToApproval( - id: waitId, - decision: decision, - scope: nil, - idempotencyKey: key - ) + _ = try await aidenBotWithin(requestLimit) { [transport] in + try await transport.respondToApproval(id: waitId, decision: decision, scope: nil, idempotencyKey: key) + } retainedApproval = nil - if approval?.waitId == waitId { approval = nil } - } catch is CancellationError { - return } catch { if !aidenBotSessionFailureIsAmbiguous(error) { retainedApproval = nil } if case let AidenRemoteClientError.server(_, body) = error, ["approval_expired", "approval_already_resolved"].contains(body.code.rawValue) { - if approval?.waitId == waitId { approval = nil } return } + if approvalWrites == mark { approval = current } + if error is CancellationError { return } errorMessage = "That answer wasn’t sent. Please try again." } } // MARK: Turn controls - /// Sends one message. A Bot that needs an AI model sends nothing. + /// Sends one message (R14). It shows at once as an outgoing message, and + /// goes out now when connected, or from the outbox on the next connection, + /// under one Idempotency-Key. A Bot that needs an AI model takes nothing. + /// While it works, on a Mac offering `bot-activity-v1`, `whenBusy` says + /// Steer or Queue; otherwise it is left off, because an older Mac refuses it. + /// + /// Returns true when the message was taken (it shows), false when refused. @discardableResult - func send(_ rawText: String) async -> Bool { + func send(_ rawText: String, whenBusy mode: AidenStreamInputMode? = nil) async -> Bool { let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) - guard canSend, let request = try? AidenBotMessageRequest(text: text) else { return false } - let key: UUID - if let retainedMessage, retainedMessage.text == text { - key = retainedMessage.key - } else { - key = UUID() - retainedMessage = (text, key) + guard canSend, (try? AidenBotMessageRequest(text: text)) != nil, + enqueue(text, mode: offersBusyModes ? mode ?? .queue : nil, retryOf: nil) else { return false } + await runFlush() + return true + } + + static let outboxFull = "Too many messages are waiting. Send or remove some, then try again." + + /// What a message takes on disk at most: its text twice as JSON, its baseline ids, and fields. + private static func storedBytes(_ text: String, baseline: Int) -> Int { + let encoded = (try? JSONEncoder().encode(text).count) ?? text.utf8.count * 6 + return 2 * encoded + baseline * (AidenRemoteProtocol.maxBotIdentifierLength + 4) + 1_024 + } + + /// Adds a message, unless the chat already holds as many as it can keep: then it says so. + private func enqueue(_ text: String, mode: AidenStreamInputMode?, retryOf: String?) -> Bool { + let bytes = outbox.reduce(Self.storedBytes(text, baseline: entries.count)) { + $0 + Self.storedBytes($1.text, baseline: $1.baseline.count) } - inFlight.insert(.send) - defer { inFlight.remove(.send) } - do { - let receipt = try await transport.sendBotMessage(botId: botID, request: request, idempotencyKey: key) - retainedMessage = nil - stateView = receipt.stateView - return true - } catch { - if !aidenBotSessionFailureIsAmbiguous(error) { retainedMessage = nil } - errorMessage = "That message wasn’t sent. Please try again." + guard outbox.count < AidenBotOutboxStore.maxMessages, bytes <= AidenBotOutboxStore.maxBytes else { + errorMessage = Self.outboxFull return false } + outbox.append(AidenBotOutgoingMessage( + key: UUID(), + text: text, + mode: mode, + enqueuedWhileRunning: isRunning, + retryOf: retryOf, + createdAt: now(), + status: .sending + )) + return true } - /// The newest entry when it is a failed turn that can be sent again. + /// Try again on a message that couldn't be sent. A message the Mac may hold + /// keeps its key, so the Mac replays its receipt instead of taking it twice. + @discardableResult + func retrySend(_ id: String) async -> Bool { + guard let index = outbox.firstIndex(where: { $0.id == id }), outbox[index].status == .failed else { return false } + if !outbox[index].keepsKey { + outbox[index].key = UUID() + outbox[index].request = nil + } + outbox[index] = AidenBotOutgoingMessage( + key: outbox[index].key, + text: outbox[index].text, + mode: outbox[index].mode, + enqueuedWhileRunning: outbox[index].enqueuedWhileRunning, + retryOf: outbox[index].retryOf, + // Try again starts its day again. + createdAt: now(), + status: .sending, + request: outbox[index].request, + baseline: outbox[index].baseline, + foreignSubmissionIDs: outbox[index].foreignSubmissionIDs, + keepsKey: outbox[index].keepsKey + ) + await runFlush() + return true + } + + /// Removes a message that couldn't be sent; nothing else is ever dropped silently. + func discardSend(_ id: String) { + outbox.removeAll { $0.id == id && $0.status == .failed } + } + + /// The newest entry when it is a failed turn that can be sent again, and no + /// Retry of it is on its way. var retryableFailedTurn: AidenBotFailedTurn? { guard case let .failedTurn(turn) = entries.last, let text = turn.retryText, - (try? AidenBotMessageRequest(text: text)) != nil else { return nil } + (try? AidenBotMessageRequest(text: text)) != nil, + !outbox.contains(where: { $0.retryOf == turn.id && $0.status != .failed }) else { return nil } return turn } - /// Sends a failed turn's text again as a new message. A Retry is its own - /// submission with its own key, never the original message's; a Retry - /// that failed ambiguously is tapped again under the same key. + /// Sends a failed turn's text again through the outbox (R18): it shows at + /// once and goes out with the other messages. A Retry is its own submission + /// with its own key, never the original message's; a Retry that couldn't be + /// sent is tried again under its key when the Mac may hold it. @discardableResult func retry() async -> Bool { - guard canSend, !inFlight.contains(.retry), let turn = retryableFailedTurn, - let text = turn.retryText, let request = try? AidenBotMessageRequest(text: text) else { return false } - let key: UUID - if let retainedRetry, retainedRetry.turnID == turn.id { - key = retainedRetry.key - } else { - key = UUID() - retainedRetry = (turn.id, key) - } - inFlight.insert(.retry) - defer { inFlight.remove(.retry) } - do { - let receipt = try await transport.sendBotMessage(botId: botID, request: request, idempotencyKey: key) - retainedRetry = nil - stateView = receipt.stateView - return true - } catch is CancellationError { - return false - } catch { - if !aidenBotSessionFailureIsAmbiguous(error) { retainedRetry = nil } - errorMessage = "That message wasn’t sent. Please try again." - return false + guard hasLoaded, canSend, let turn = retryableFailedTurn, let text = turn.retryText else { return false } + if let earlier = outbox.first(where: { $0.retryOf == turn.id }) { + return await retrySend(earlier.id) } + guard enqueue(text, mode: offersBusyModes ? .queue : nil, retryOf: turn.id) else { return false } + await runFlush() + return true } + /// Resume shows "Picking up where it left off" at once and goes back if the + /// Mac didn't take it (R18). func resume() async { - guard isInterrupted, !isAccessBlocked else { return } - await perform(.resume) { [transport, botID] key in + guard isInterrupted, !isAccessBlocked, !inFlight.contains(.resume) else { return } + resumeIntent = .requested + let resumed = await perform(.resume, optimistic: try? AidenBotSessionStateView(state: .running)) { [transport, botID] key in try await transport.resumeBotSession(botId: botID, idempotencyKey: key) } + if !resumed { + resumeIntent = .none + } else if resumeIntent == .requested { + resumeIntent = .acknowledged + } } + /// Dismiss clears the paused card at once and brings it back if the Mac didn't take it (R18). func dismiss() async { guard isInterrupted else { return } - await perform(.dismiss) { [transport, botID] key in + await perform(.dismiss, optimistic: try? AidenBotSessionStateView(state: .idle)) { [transport, botID] key in try await transport.dismissBotSession(botId: botID, idempotencyKey: key) } } + /// Stop says "Stopping" at once (R14) and goes back if the Mac didn't take it. func stop() async { - guard isRunning else { return } - await perform(.stop) { [transport, botID] key in + guard isRunning, !inFlight.contains(.stop) else { return } + stopIntent = .requested + stopStartedAt = activity?.startedAt + let stopped = await perform(.stop) { [transport, botID] key in try await transport.stopBotSession(botId: botID, idempotencyKey: key) } + if !stopped { + stopIntent = .none + } else if stopIntent == .requested { + stopIntent = .acknowledged + } } - /// One request per logical action at a time. The key survives an - /// ambiguous failure so a retry is deduplicated by the Mac. + /// One request per logical action at a time. The key survives an ambiguous + /// failure so a retry is deduplicated by the Mac. `optimistic` shows at once + /// and goes back on failure; the Mac's answer is applied only if no frame + /// moved the session meanwhile. + @discardableResult private func perform( _ action: Action, + optimistic: AidenBotSessionStateView? = nil, _ request: @escaping @Sendable (UUID) async throws -> AidenBotSessionStateView - ) async { - guard !inFlight.contains(action) else { return } + ) async -> Bool { + guard !inFlight.contains(action) else { return false } let key = retainedKeys[action] ?? UUID() retainedKeys[action] = key + let previous = stateView + let writes = stateWrites + if let optimistic { stateView = optimistic } inFlight.insert(action) defer { inFlight.remove(action) } do { - stateView = try await request(key) + let view = try await aidenBotWithin(requestLimit) { try await request(key) } + // The Mac's answer stands unless it has written the state again since. + if stateWrites == writes { setStateView(view) } retainedKeys[action] = nil - } catch is CancellationError { - return + return true } catch { + // Only what the Mac hasn't written since the tap goes back. + if optimistic != nil, stateWrites == writes { stateView = previous } + if error is CancellationError { return false } if !aidenBotSessionFailureIsAmbiguous(error) { retainedKeys[action] = nil } errorMessage = "That didn’t work. Please try again." + return false } } // MARK: Routine proposals - /// Answers a pending routine proposal. The card shows the Mac's settled - /// answer as soon as it arrives (the same answer on every repeat, from any - /// device). A failure keeps the card pending with its actions; the key - /// survives an ambiguous failure so tapping again cannot add twice. + /// Answers a pending routine proposal. The card settles at once and goes + /// back to pending with an error if the answer didn't reach the Mac (R18); + /// the Mac's settled answer replaces it when it arrives (the same answer on + /// every repeat, from any device). The key survives an ambiguous failure so + /// tapping again cannot add twice. @discardableResult func respondToProposal(_ proposalId: String, decision: AidenBotRoutineProposalDecision) async -> Bool { guard hasLoaded, !respondingProposals.contains(proposalId), @@ -545,23 +1929,38 @@ final class AidenBotSessionModel { key = UUID() proposalKeys[proposalId] = (decision, key) } + let optimistic = AidenBotSessionEntry.routineProposal(AidenBotRoutineProposalCard( + id: card.id, + proposalId: card.proposalId, + name: card.name, + prompt: card.prompt, + label: card.label, + status: decision == .accept ? .accepted : .dismissed, + routineId: nil, + createdAt: card.createdAt + )) + entries[index] = optimistic respondingProposals.insert(proposalId) defer { respondingProposals.remove(proposalId) } do { - let result = try await transport.respondToBotRoutineProposal( - botId: botID, - proposalId: proposalId, - request: AidenBotRoutineProposalRespondRequest(decision: decision), - idempotencyKey: key - ) + let result = try await aidenBotWithin(requestLimit) { [transport, botID] in + try await transport.respondToBotRoutineProposal( + botId: botID, + proposalId: proposalId, + request: AidenBotRoutineProposalRespondRequest(decision: decision), + idempotencyKey: key + ) + } proposalKeys[proposalId] = nil if let current = proposalIndex(proposalId), case let .routineProposal(latest) = entries[current] { entries[current] = .routineProposal(latest.settled(result)) } return true - } catch is CancellationError { - return false } catch { + if let current = proposalIndex(proposalId), entries[current] == optimistic { + entries[current] = .routineProposal(card) + } + if error is CancellationError { return false } if !aidenBotSessionFailureIsAmbiguous(error) { proposalKeys[proposalId] = nil } if case AidenRemoteClientError.server(404, _) = error { // The proposal is gone on the Mac; show what the Mac has now. @@ -588,7 +1987,9 @@ final class AidenBotSessionModel { connectionKeys[pluginId] = key sentConnectionRequests.insert(pluginId) do { - _ = try await transport.requestBotConnection(botId: botID, request: request, idempotencyKey: key) + _ = try await aidenBotWithin(requestLimit) { [transport, botID] in + try await transport.requestBotConnection(botId: botID, request: request, idempotencyKey: key) + } connectionKeys[pluginId] = nil } catch is CancellationError { sentConnectionRequests.remove(pluginId) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSettingsDraft.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSettingsDraft.swift index b465a0ad4..29d339ad7 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSettingsDraft.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSettingsDraft.swift @@ -22,9 +22,21 @@ struct AidenBotCustomAccessSessionIdentity: Equatable { installation?.serverCapabilities?.map(\.rawValue).sorted().joined(separator: ",") ?? "legacy", ].joined(separator: "|") } + + /// The same pairing and grants, whatever the connection is doing. Bot pages + /// start over when this changes; a connection coming up only re-reads (R15). + struct Pairing: Equatable, Hashable { + let instanceID: String? + let deviceID: String? + let capabilityRevision: String + } + + var pairing: Pairing { + Pairing(instanceID: instanceID, deviceID: deviceID, capabilityRevision: capabilityRevision) + } } -struct AidenBotCustomAccessDraft: Equatable { +struct AidenBotCustomAccessDraft: Codable, Equatable { var providerID: String var modelID: String var fileScopeIDs: Set @@ -173,7 +185,7 @@ extension AidenBotCapabilityCatalog { } } -struct AidenBotEditorDraft: Equatable { +struct AidenBotEditorDraft: Codable, Equatable { var name: String var purpose: String var instructions: String diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift index c937e53f0..af4223380 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift @@ -276,6 +276,10 @@ struct AidenBotsHomeView: View { @State private var presets: [AidenBotPreset] = [] @State private var presetAttempt: (presetID: String, key: UUID)? @State private var isStartingPreset = false + /// Bot creation outlives New Bot (R15): the row shows at once, a failure offers Try Again. + @State private var createFlow = AidenBotCreateFlow() + /// The failed create New Bot reopens with after Try Again. + @State private var createPrefill: AidenBotCreateFlow.Failure? @FocusState private var searchIsFocused: Bool private var loadID: AidenBotsHomeLoadID { @@ -342,10 +346,30 @@ struct AidenBotsHomeView: View { ) } + /// The pairing in use, as a create flow keys what it keeps. + private var activePairing: (instanceId: String, deviceId: String)? { + coordinator.installationStore.activeInstallation.map { ($0.id, $0.deviceId) } + } + + /// The Bot on its way, when it is on its way to the Mac in use. + private var activePendingCreate: AidenBotCreateFlow.Pending? { + guard let pending = createFlow.pending, let pairing = activePairing, + pending.instanceId == pairing.instanceId, pending.deviceId == pairing.deviceId else { return nil } + return pending + } + + /// The failed create of the Mac in use; another Mac's waits for that Mac. It shows once no + /// other Bot is on its way, since one Bot is made at a time. + private var activeCreateFailure: AidenBotCreateFlow.Failure? { + guard createFlow.pending == nil else { return nil } + return activePairing.flatMap { createFlow.failure(instanceId: $0.instanceId, deviceId: $0.deviceId) } + } + + /// New Bot opens at once, connected or not (R15); the Bot is made once the Mac answers. private var canCreateBot: Bool { availability.canWrite - && coordinator.connectionState == .connected && coordinator.installationStore.activeInstallation?.canWriteBots == true + && createFlow.pending == nil } var body: some View { @@ -376,14 +400,18 @@ struct AidenBotsHomeView: View { homeScroll } } - .sheet(item: $presentedSheet) { sheet in + .sheet(item: $presentedSheet, onDismiss: { + // Try Again reopened New Bot and it closed without Create: that Bot is let go. + if let reopened = createPrefill { + createPrefill = nil + createFlow.discard(reopened) + } + }) { sheet in switch sheet { case .create: - AidenBotCreateView(coordinator: coordinator) { created in - Task { - await load() - openChat(for: AidenBotSummary(detail: created)) - } + AidenBotCreateView(coordinator: coordinator, initial: createPrefill?.input) { input in + // Reopened by Try Again: it goes only to the Mac it was for. + startCreate(input, retrying: createPrefill) } case let .profile(bot): AidenBotProfileView( @@ -416,6 +444,40 @@ struct AidenBotsHomeView: View { ) { if let bot = pendingDelete { Task { await delete(bot) } } } + .alert( + AidenBotCreateCopy.failedTitle, + isPresented: Binding( + get: { activeCreateFailure != nil }, + // Both buttons settle the failure themselves; a switch of Mac only hides it. + set: { _ in } + ), + presenting: activeCreateFailure + ) { failure in + Button("Try Again") { + guard let pairing = activePairing else { return } + switch createFlow.retry( + failure, + instanceId: pairing.instanceId, + deviceId: pairing.deviceId, + bots: listedBots(for: failure) + ) { + case let .send(input): + // An earlier launch's create: the same request, on the same key. + startCreate(input, retrying: failure) + case let .reopen(reopened): + createPrefill = reopened + presentedSheet = .create + case .settled, .none: + break + } + } + Button("OK", role: .cancel) { + createPrefill = nil + createFlow.discard(failure) + } + } message: { failure in + Text(failure.message) + } .alert( "Couldn’t Delete", isPresented: Binding( @@ -428,6 +490,13 @@ struct AidenBotsHomeView: View { Text(deleteError ?? "Please try again.") } .task(id: loadID) { + // A New Bot an earlier launch left on its way comes back with Try Again, on its own Mac only. + if loadID.isBotSurfaceActive, let context = try? coordinator.requestContext() { + let coordinator = coordinator + await createFlow.restore(instanceId: context.instanceId, deviceId: context.deviceId) { + coordinator.isCurrent(context) + } + } await load() } .task(id: searchID) { @@ -477,6 +546,7 @@ struct AidenBotsHomeView: View { } .disabled(allRows.isEmpty && !isSearching) headerButton(systemImage: "plus", label: "New Bot") { + createPrefill = nil presentedSheet = .create } .disabled(!canCreateBot) @@ -527,16 +597,24 @@ struct AidenBotsHomeView: View { @ViewBuilder private var content: some View { + if let pending = activePendingCreate { + AidenBotPendingCreateRow(pending: pending) + } switch contentState { case .loading: AidenBotHomeSkeletonView(reduceMotion: reduceMotion) + case .empty where activePendingCreate != nil: + EmptyView() case .empty where coordinator.connectionState == .connected && !presets.isEmpty: AidenBotMeetFirstBotView( presets: presets, isBusy: isStartingPreset, canCreate: canCreateBot, onStartChat: { preset in Task { await startChat(with: preset) } }, - onCreateOwn: { presentedSheet = .create } + onCreateOwn: { + createPrefill = nil + presentedSheet = .create + } ) case .empty: ContentUnavailableView { @@ -551,8 +629,11 @@ struct AidenBotsHomeView: View { : "Connect to your Mac to see your Bots." ) } actions: { - if coordinator.connectionState == .connected { - Button("New Bot") { presentedSheet = .create } + if canCreateBot { + Button("New Bot") { + createPrefill = nil + presentedSheet = .create + } .buttonStyle(.borderedProminent) .buttonBorderShape(.capsule) .tint(palette.accent) @@ -585,18 +666,9 @@ struct AidenBotsHomeView: View { botID: row.bot.id, avatar: row.bot.avatar, name: row.bot.name, - size: 52 + size: 52, + working: row.isWorking ) - .overlay(alignment: .bottomTrailing) { - if row.isWorking { - Circle() - .fill(palette.success) - .frame(width: 14, height: 14) - .padding(2) - .background(palette.canvas, in: Circle()) - .accessibilityHidden(true) - } - } VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Text(row.bot.name) @@ -672,6 +744,40 @@ struct AidenBotsHomeView: View { .padding(.vertical, 10) } + /// The Bots the list shows for the failure's own pairing, to find one a lost create made. + private func listedBots(for failure: AidenBotCreateFlow.Failure) -> [AidenBotSummary] { + snapshotScope == AidenBotsHomeScope(instanceID: failure.instanceId, deviceID: failure.deviceId) ? allBots : [] + } + + /// Makes the Bot New Bot asked for, after the sheet closed (R15). `retrying` is the failure + /// a Try Again came from: the flow sends it only to that failure's own Mac. + @MainActor + private func startCreate(_ input: AidenBotCreateInput, retrying: AidenBotCreateFlow.Failure? = nil) { + guard let context = try? coordinator.requestContext() else { return } + createPrefill = nil + let coordinator = coordinator + let operations = AidenBotCreateOperations( + instanceId: context.instanceId, + deviceId: context.deviceId, + usesDurableSession: AidenBotHostFeature.isAdvertised(AidenBotHostFeature.durableSession, coordinator: coordinator), + waitForConnection: { hold in await aidenBotWaitForConnection(coordinator, hold: hold) }, + catalog: { try await coordinator.remoteClient(for: context).botCapabilityCatalog() }, + create: { request, key in try await coordinator.remoteClient(for: context).createBot(request, idempotencyKey: key) }, + createChat: { botID, request, key in + _ = try await coordinator.remoteClient(for: context).createBotChat(botId: botID, request: request, idempotencyKey: key) + }, + readBot: { botID in try await coordinator.remoteClient(for: context).bot(id: botID) }, + isCurrent: { coordinator.isCurrent(context) }, + handleRevocation: { error in await coordinator.handleCredentialRevocation(error, context: context) } + ) + Task { + await createFlow.submit(input, operations: operations, retrying: retrying) { created in + await load() + openChat(for: AidenBotSummary(detail: created)) + } + } + } + /// Opens the Bot's one chat, starting it the first time if needed. @MainActor private func openChat(for bot: AidenBotSummary) { diff --git a/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift b/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift index 3e8373677..7fe8110b1 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift @@ -384,9 +384,13 @@ struct AidenBotConversationFilesView: View { } } Section("Files") { - ForEach(entries) { entry in - fileRow(entry) - .disabled(entry.kind != .file || coordinator.connectionState != .connected) + if model.isLoading && model.index == nil { + AidenBotFilesLoadingRows() + } else { + ForEach(entries) { entry in + fileRow(entry) + .disabled(entry.kind != .file || coordinator.connectionState != .connected) + } } } } @@ -424,8 +428,7 @@ struct AidenBotConversationFilesView: View { @ViewBuilder private var fileOverlay: some View { - if model.isLoading && model.index == nil { ProgressView("Loading files…") } - else if !model.isLoading, model.index?.entries.isEmpty == true { + if !model.isLoading, model.index?.entries.isEmpty == true { ContentUnavailableView( "No Files Yet", systemImage: "folder", @@ -435,6 +438,30 @@ struct AidenBotConversationFilesView: View { } } +/// The Files list while its first read is on the way: skeleton rows shaped like +/// files, never a spinner (R15). VoiceOver hears one "Loading files". +struct AidenBotFilesLoadingRows: View { + @Environment(\.aidenReduceMotion) private var reduceMotion + + private static let widths: [(name: CGFloat, path: CGFloat)] = [(140, 200), (110, 170), (160, 220), (96, 150), (128, 190)] + + var body: some View { + ForEach(Self.widths.indices, id: \.self) { index in + HStack(spacing: 12) { + AidenBotSkeletonBlock(width: 22, height: 22, radius: 6, reduceMotion: reduceMotion) + VStack(alignment: .leading, spacing: 6) { + AidenBotSkeletonBlock(width: Self.widths[index].name, height: 14, radius: 7, reduceMotion: reduceMotion) + AidenBotSkeletonBlock(width: Self.widths[index].path, height: 10, radius: 5, reduceMotion: reduceMotion) + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Loading files") + .accessibilityHidden(index > 0) + } + } +} + private struct AidenBotConversationFileEditorView: View { @Environment(\.dismiss) private var dismiss @Bindable var coordinator: AidenRemoteCoordinator diff --git a/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift b/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift index 2cbf2c4e9..b57929161 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift @@ -796,6 +796,7 @@ final class AidenRemoteCoordinator { if server.supportsPhoneRunControl { requested += [.runsObserve, .runsControl] } if server.supportsMobileSimulators { requested.append(.simulatorsMobile) } if server.supportsBotCards { requested.append(.botCards) } + if server.offersBotActivity { requested.append(.botActivity) } guard !requested.isEmpty, let installation = installationStore.installations.first(where: { $0.id == installationId }) else { return @@ -993,6 +994,8 @@ final class AidenRemoteCoordinator { AidenChatForkPrefillHandoff.purge(instanceId: installationId) await AidenBotCache.shared.purge(instanceId: installationId) await AidenChatDraftStore.shared.purge(instanceId: installationId) + AidenBotSessionCache.shared.purge(instanceId: installationId) + await AidenBotOutboxStore.shared.purge(instanceId: installationId) AidenModelPreferenceStore.shared.purge(instanceID: installationId) await chatCache.purge(instanceId: installationId) await scheduledTaskCache.purge(instanceId: installationId) diff --git a/ios/AidenOnTheGo/Models/AidenBot.swift b/ios/AidenOnTheGo/Models/AidenBot.swift index 309509c26..5c8465844 100644 --- a/ios/AidenOnTheGo/Models/AidenBot.swift +++ b/ios/AidenOnTheGo/Models/AidenBot.swift @@ -431,6 +431,8 @@ struct AidenBotDetail: Codable, Equatable, Identifiable, Sendable { let access: AidenBotAccessView let modelSelection: AidenBotModelSelection? let visionModelSelection: AidenBotModelSelection? + /// Revision 30 (`bot:activity`): the Bot's Chat activity choice. + let chatActivity: AidenBotChatActivity? let createdAt: Date let updatedAt: Date let revision: String @@ -476,6 +478,11 @@ struct AidenBotDetail: Codable, Equatable, Identifiable, Sendable { from: values, forKey: .visionModelSelection ) + chatActivity = try AidenBotWire.optional( + AidenBotChatActivity.self, + from: values, + forKey: .chatActivity + ) let createdTimestamp = try values.decode(AidenRemoteTimestamp.self, forKey: .createdAt) createdAt = createdTimestamp.date let updatedTimestamp = try values.decode(AidenRemoteTimestamp.self, forKey: .updatedAt) @@ -518,6 +525,37 @@ struct AidenBotModelSelection: Codable, Equatable, Sendable { } } +/// A Bot's Chat activity choice (spec §7, contract revision 30). +enum AidenBotChatActivityLevel: String, Codable, CaseIterable, Sendable { + case quiet, summary, detailed +} + +enum AidenBotChatActivityWording: String, Codable, CaseIterable, Sendable { + case follows, thinking +} + +struct AidenBotChatActivity: Codable, Equatable, Sendable { + var level: AidenBotChatActivityLevel + var wording: AidenBotChatActivityWording + + /// What a Bot that never chose shows: Quiet, Follows the work. + static let standard = AidenBotChatActivity(level: .quiet, wording: .follows) + + init(level: AidenBotChatActivityLevel, wording: AidenBotChatActivityWording) { + self.level = level + self.wording = wording + } + + init(from decoder: Decoder) throws { + try AidenBotWire.requireOnlyKeys(decoder, allowed: ["level", "wording"]) + let values = try decoder.container(keyedBy: CodingKeys.self) + level = try values.decode(AidenBotChatActivityLevel.self, forKey: .level) + wording = try values.decode(AidenBotChatActivityWording.self, forKey: .wording) + } + + private enum CodingKeys: String, CodingKey { case level, wording } +} + struct AidenBotCreateRequest: Codable, Equatable, Sendable { let name: String let purpose: String @@ -578,14 +616,16 @@ struct AidenBotIdentityPatch: Codable, Equatable, Sendable { let purpose: String? let instructions: String? let avatar: AidenBotSemanticAvatar? + let chatActivity: AidenBotChatActivity? init( name: String? = nil, purpose: String? = nil, instructions: String? = nil, - avatar: AidenBotSemanticAvatar? = nil + avatar: AidenBotSemanticAvatar? = nil, + chatActivity: AidenBotChatActivity? = nil ) throws { - guard name != nil || purpose != nil || instructions != nil || avatar != nil else { + guard name != nil || purpose != nil || instructions != nil || avatar != nil || chatActivity != nil else { throw AidenBotContractError.invalidCombination("empty identity patch") } if let name { try AidenBotWire.validateString(name, field: "name", maxLength: AidenBotWire.maxNameLength, allowEmpty: false) } @@ -595,6 +635,7 @@ struct AidenBotIdentityPatch: Codable, Equatable, Sendable { self.purpose = purpose self.instructions = instructions self.avatar = avatar + self.chatActivity = chatActivity } /// A patch body from an older client may still carry `openingGreeting`; @@ -624,13 +665,14 @@ struct AidenBotIdentityPatch: Codable, Equatable, Sendable { } else { avatar = nil } - guard name != nil || purpose != nil || instructions != nil || avatar != nil else { + chatActivity = try values.decodeIfPresent(AidenBotChatActivity.self, forKey: .chatActivity) + guard name != nil || purpose != nil || instructions != nil || avatar != nil || chatActivity != nil else { throw AidenBotContractError.invalidCombination("empty identity patch") } } private enum CodingKeys: String, CodingKey, CaseIterable { - case name, purpose, instructions, avatar + case name, purpose, instructions, avatar, chatActivity } } diff --git a/ios/AidenOnTheGo/Models/AidenBotMemory.swift b/ios/AidenOnTheGo/Models/AidenBotMemory.swift index 6b46d8fcc..0ec525014 100644 --- a/ios/AidenOnTheGo/Models/AidenBotMemory.swift +++ b/ios/AidenOnTheGo/Models/AidenBotMemory.swift @@ -137,6 +137,76 @@ struct AidenBotMemory: Codable, Equatable, Sendable { /// Every remembered entry, across both groups. var entryCount: Int { user.entries.count + memory.entries.count } + init( + botId: String, + revision: String, + readable: Bool, + memory: AidenBotMemoryGroup, + user: AidenBotMemoryGroup, + updatedAt: AidenRemoteTimestamp? + ) { + self.botId = botId + self.revision = revision + self.readable = readable + self.memory = memory + self.user = user + self.updatedAt = updatedAt + } + + /// The view as it will look once the Mac applies `edit`, shown at once (R15). + /// Nil when the edit names an entry this view doesn't hold. The Mac's own + /// view replaces it when it answers. + func applying(_ edit: AidenBotMemoryEdit) -> AidenBotMemory? { + func size(_ text: String) -> Int { text.unicodeScalars.count } + func changed(_ group: AidenBotMemoryGroup, entries: [AidenBotMemoryEntry], used: Int) -> AidenBotMemoryGroup? { + let used = min(max(0, used), AidenBotMemoryWire.maxChars) + return try? AidenBotMemoryGroup( + entries: entries, + usedChars: used, + limitChars: group.limitChars, + overBudget: used > group.limitChars + ) + } + func with(_ target: AidenBotMemoryTarget, _ next: AidenBotMemoryGroup) -> AidenBotMemory { + AidenBotMemory( + botId: botId, + revision: revision, + readable: readable, + memory: target == .memory ? next : memory, + user: target == .user ? next : user, + updatedAt: updatedAt + ) + } + switch edit { + case let .replace(target, entryId, text): + let current = group(target) + guard let old = current.entries.first(where: { $0.id == entryId }), + let replacement = try? AidenBotMemoryEntry(id: entryId, text: text), + let next = changed( + current, + entries: current.entries.map { $0.id == entryId ? replacement : $0 }, + used: current.usedChars - size(old.text) + size(text) + ) else { return nil } + return with(target, next) + case let .remove(target, entryId): + let current = group(target) + guard let old = current.entries.first(where: { $0.id == entryId }), + let next = changed( + current, + entries: current.entries.filter { $0.id != entryId }, + used: current.usedChars - size(old.text) + ) else { return nil } + return with(target, next) + case .clear: + guard let emptyMemory = changed(memory, entries: [], used: 0), + let emptyUser = changed(user, entries: [], used: 0) else { return nil } + return AidenBotMemory( + botId: botId, revision: revision, readable: true, + memory: emptyMemory, user: emptyUser, updatedAt: updatedAt + ) + } + } + private enum CodingKeys: String, CodingKey { case botId, revision, readable, memory, user, updatedAt } } diff --git a/ios/AidenOnTheGo/Models/AidenBotSession.swift b/ios/AidenOnTheGo/Models/AidenBotSession.swift index 34249becf..ffc6698df 100644 --- a/ios/AidenOnTheGo/Models/AidenBotSession.swift +++ b/ios/AidenOnTheGo/Models/AidenBotSession.swift @@ -29,6 +29,20 @@ enum AidenBotSessionWire { } } + /// Submission ids: 1–64 ASCII letters, digits, `.`, `_`, `:` or `-`. + static func validateSubmissionID(_ value: String, field: String) throws { + let scalars = Array(value.unicodeScalars) + guard (1...maxSubmissionIDLength).contains(scalars.count), + scalars.allSatisfy({ scalar in + switch scalar.value { + case 48...57, 65...90, 97...122, 46, 95, 58, 45: true + default: false + } + }) else { + throw AidenBotContractError.invalidField(field) + } + } + static func validateEpoch(_ value: String) throws { let scalars = Array(value.unicodeScalars) guard (1...maxEpochLength).contains(scalars.count), @@ -128,6 +142,28 @@ struct AidenBotSessionMessage: Equatable, Sendable { let label: String? /// An assistant answer that was cut off. let interrupted: Bool + /// Contract revision 30 (`bot:activity`): the submission the person's own + /// message was sent as, the same id as the send receipt and the queued + /// list. Absent on every other message and whenever the Mac no longer knows it. + let submissionId: String? + + init( + id: String, + role: AidenBotSessionMessageRole, + text: String, + createdAt: AidenRemoteTimestamp?, + label: String?, + interrupted: Bool, + submissionId: String? = nil + ) { + self.id = id + self.role = role + self.text = text + self.createdAt = createdAt + self.label = label + self.interrupted = interrupted + self.submissionId = submissionId + } } enum AidenBotConnectCardStatus: String, Codable, Sendable { @@ -231,13 +267,21 @@ enum AidenBotSessionEntry: Codable, Equatable, Identifiable, Sendable { case "message": try AidenBotWire.requireOnlyKeys( decoder, - allowed: ["type", "id", "role", "text", "createdAt", "label", "interrupted"] + allowed: ["type", "id", "role", "text", "createdAt", "label", "interrupted", "submissionId"] ) let role = try values.decode(AidenBotSessionMessageRole.self, forKey: .role) let interrupted = try AidenBotWire.optional(Bool.self, from: values, forKey: .interrupted) guard interrupted == nil || (interrupted == true && role == .assistant) else { throw AidenBotContractError.invalidCombination("interrupted message") } + let submissionId = try AidenBotWire.optional(String.self, from: values, forKey: .submissionId) + if let submissionId { + // Only the person's own message: never an assistant's, never a routine's labelled input. + guard role == .user, !values.contains(.label) else { + throw AidenBotContractError.invalidCombination("message submissionId") + } + try AidenBotSessionWire.validateSubmissionID(submissionId, field: "message.submissionId") + } self = .message(AidenBotSessionMessage( id: id, role: role, @@ -249,7 +293,8 @@ enum AidenBotSessionEntry: Codable, Equatable, Identifiable, Sendable { ), createdAt: try AidenBotWire.optional(AidenRemoteTimestamp.self, from: values, forKey: .createdAt), label: try AidenBotWire.optionalString(values, forKey: .label, maxLength: AidenBotSessionWire.maxLabelLength), - interrupted: interrupted == true + interrupted: interrupted == true, + submissionId: submissionId )) case "connect_card": try AidenBotWire.requireOnlyKeys( @@ -341,6 +386,7 @@ enum AidenBotSessionEntry: Codable, Equatable, Identifiable, Sendable { try values.encodeIfPresent(message.createdAt, forKey: .createdAt) try values.encodeIfPresent(message.label, forKey: .label) if message.interrupted { try values.encode(true, forKey: .interrupted) } + try values.encodeIfPresent(message.submissionId, forKey: .submissionId) case let .connectCard(card): try values.encode("connect_card", forKey: .type) try values.encode(card.id, forKey: .id) @@ -376,7 +422,7 @@ enum AidenBotSessionEntry: Codable, Equatable, Identifiable, Sendable { } private enum CodingKeys: String, CodingKey { - case type, id, role, text, createdAt, label, interrupted + case type, id, role, text, createdAt, label, interrupted, submissionId case pluginId, name, iconId, reason, status, notice, retryText case proposalId, prompt, routineId } @@ -497,6 +543,91 @@ struct AidenBotQuestionAnswerReceipt: Codable, Equatable, Sendable { } } +// MARK: Contract revision 30 (`bot-activity-v1`, `bot:activity`) + +/// What a Bot in a running turn is doing (spec §5). Exactly `phase`, `app` +/// and `startedAt` cross the wire; any other key is refused (§11). +struct AidenBotSessionActivity: Codable, Equatable, Sendable { + static let maxAppLength = 40 + + let phase: String + /// The connection's label for the `app` phase. + let app: String? + /// When the turn started: the Mac's clock, milliseconds since 1970. + let startedAt: Int + + init(phase: String, app: String? = nil, startedAt: Int) { + self.phase = phase + self.app = app + self.startedAt = startedAt + } + + init(from decoder: Decoder) throws { + try AidenBotWire.requireOnlyKeys(decoder, allowed: ["phase", "app", "startedAt"]) + let values = try decoder.container(keyedBy: CodingKeys.self) + phase = try values.decode(String.self, forKey: .phase) + guard Self.isPhaseID(phase) else { throw AidenBotContractError.invalidField("activity.phase") } + app = try AidenBotWire.optionalString(values, forKey: .app, maxLength: Self.maxAppLength) + startedAt = try values.decode(Int.self, forKey: .startedAt) + guard (0...AidenRemoteProtocol.maxSafeInteger).contains(startedAt) else { + throw AidenBotContractError.invalidField("activity.startedAt") + } + } + + /// `^[a-z][a-z_]{0,31}$`: a newer Mac's phase still decodes, and reads as Working. + static func isPhaseID(_ value: String) -> Bool { + let scalars = Array(value.unicodeScalars) + guard (1...32).contains(scalars.count), let first = scalars.first, (97...122).contains(first.value) else { + return false + } + return scalars.allSatisfy { (97...122).contains($0.value) || $0 == "_" } + } + + private enum CodingKeys: String, CodingKey { case phase, app, startedAt } +} + +/// A message waiting for the running turn: `steer` joins it, `queue` runs after it. +struct AidenBotQueuedMessage: Codable, Equatable, Sendable { + static let maxCount = 20 + + let submissionId: String + let mode: AidenStreamInputMode + let text: String + + init(submissionId: String, mode: AidenStreamInputMode, text: String) { + self.submissionId = submissionId + self.mode = mode + self.text = text + } + + init(from decoder: Decoder) throws { + try AidenBotWire.requireOnlyKeys(decoder, allowed: ["submissionId", "mode", "text"]) + let values = try decoder.container(keyedBy: CodingKeys.self) + submissionId = try AidenBotWire.requiredString( + values, + forKey: .submissionId, + maxLength: AidenBotSessionWire.maxSubmissionIDLength + ) + try AidenBotSessionWire.validateSubmissionID(submissionId, field: "queued.submissionId") + mode = try values.decode(AidenStreamInputMode.self, forKey: .mode) + text = try AidenBotWire.requiredString(values, forKey: .text, maxLength: AidenBotSessionWire.maxMessageLength) + } + + /// Decodes a queued list and enforces its limits: at most 20, each id once. + /// + /// - Warning: Decode a list only through here. A bare `[AidenBotQueuedMessage]` + /// decode checks each message but skips the list limits. + static func decodeList(from container: KeyedDecodingContainer, forKey key: Key) throws -> [Self] { + let list = try container.decode([Self].self, forKey: key) + guard list.count <= maxCount, Set(list.map(\.submissionId)).count == list.count else { + throw AidenBotContractError.invalidField("queued") + } + return list + } + + private enum CodingKeys: String, CodingKey { case submissionId, mode, text } +} + /// `GET /bots/{botId}/session`: the newest entries (newest last) plus the /// in-flight assistant text, stamped with the `(epoch, seq)` it reflects. struct AidenBotSession: Codable, Equatable, Sendable { @@ -511,6 +642,10 @@ struct AidenBotSession: Codable, Equatable, Sendable { let question: AidenRemoteBotQuestion? /// The oldest tool approval the Bot is waiting on, or nil. let approval: AidenRemoteBotApproval? + /// Revision 30 (`bot:activity`): what the running turn is doing, or nil. + let activity: AidenBotSessionActivity? + /// Revision 30 (`bot:activity`): the person's messages waiting for the turn. + let queued: [AidenBotQueuedMessage] var state: AidenBotSessionStateKind { stateView.state } @@ -523,7 +658,9 @@ struct AidenBotSession: Codable, Equatable, Sendable { entries: [AidenBotSessionEntry], hasOlder: Bool, question: AidenRemoteBotQuestion? = nil, - approval: AidenRemoteBotApproval? = nil + approval: AidenRemoteBotApproval? = nil, + activity: AidenBotSessionActivity? = nil, + queued: [AidenBotQueuedMessage] = [] ) { self.botId = botId self.epoch = epoch @@ -534,12 +671,14 @@ struct AidenBotSession: Codable, Equatable, Sendable { self.hasOlder = hasOlder self.question = question self.approval = approval + self.activity = activity + self.queued = queued } init(from decoder: Decoder) throws { try AidenBotWire.requireOnlyKeys( decoder, - allowed: ["botId", "epoch", "seq", "state", "interrupted", "blocked", "partial", "entries", "hasOlder", "question", "approval"] + allowed: ["botId", "epoch", "seq", "state", "interrupted", "blocked", "partial", "entries", "hasOlder", "question", "approval", "activity", "queued"] ) let values = try decoder.container(keyedBy: CodingKeys.self) botId = try AidenBotWire.identifier(values, forKey: .botId, maxLength: AidenRemoteProtocol.maxBotIdentifierLength) @@ -562,6 +701,12 @@ struct AidenBotSession: Codable, Equatable, Sendable { hasOlder = try values.decode(Bool.self, forKey: .hasOlder) question = try values.decode(AidenRemoteBotQuestion?.self, forKey: .question) approval = try values.decode(AidenRemoteBotApproval?.self, forKey: .approval) + activity = try values.decodeIfPresent(AidenBotSessionActivity.self, forKey: .activity) + if values.contains(.queued) { + queued = try AidenBotQueuedMessage.decodeList(from: values, forKey: .queued) + } else { + queued = [] + } guard entries.count <= AidenBotSessionWire.maxEntries, Set(entries.map(\.id)).count == entries.count else { throw AidenBotContractError.invalidField("entries") @@ -579,10 +724,12 @@ struct AidenBotSession: Codable, Equatable, Sendable { try values.encode(hasOlder, forKey: .hasOlder) try values.encode(question, forKey: .question) try values.encode(approval, forKey: .approval) + try values.encodeIfPresent(activity, forKey: .activity) + if !queued.isEmpty { try values.encode(queued, forKey: .queued) } } private enum CodingKeys: String, CodingKey { - case botId, epoch, seq, partial, entries, hasOlder, question, approval + case botId, epoch, seq, partial, entries, hasOlder, question, approval, activity, queued } } @@ -603,6 +750,10 @@ struct AidenBotSessionEvent: Codable, Equatable, Sendable { case question(AidenRemoteBotQuestion?) /// The waiting tool approval appeared (`approval`) or was settled (nil). case approval(AidenRemoteBotApproval?) + /// Revision 30: what the running turn is doing now, or nil when none runs. + case activity(AidenBotSessionActivity?) + /// Revision 30: the person's messages still waiting for the turn. + case queued([AidenBotQueuedMessage]) /// The host closed this session; reconnect for a new epoch. case closed } @@ -627,6 +778,8 @@ struct AidenBotSessionEvent: Codable, Equatable, Sendable { case .state: "state" case .question: "question" case .approval: "approval" + case .activity: "activity" + case .queued: "queued" case .closed: "closed" } } @@ -678,6 +831,13 @@ struct AidenBotSessionEvent: Codable, Equatable, Sendable { case "approval": try AidenBotWire.requireOnlyKeys(payload, allowed: ["approval"]) kind = .approval(try fields.decode(AidenRemoteBotApproval?.self, forKey: .approval)) + case "activity": + try AidenBotWire.requireOnlyKeys(payload, allowed: ["activity"]) + guard fields.contains(.activity) else { throw AidenBotContractError.invalidField("activity") } + kind = .activity(try fields.decode(AidenBotSessionActivity?.self, forKey: .activity)) + case "queued": + try AidenBotWire.requireOnlyKeys(payload, allowed: ["queued"]) + kind = .queued(try AidenBotQueuedMessage.decodeList(from: fields, forKey: .queued)) case "closed": try AidenBotWire.requireOnlyKeys(payload, allowed: []) kind = .closed @@ -705,6 +865,8 @@ struct AidenBotSessionEvent: Codable, Equatable, Sendable { case let .state(view): try view.encode(to: payload) case let .question(question): try fields.encode(question, forKey: .question) case let .approval(approval): try fields.encode(approval, forKey: .approval) + case let .activity(activity): try fields.encode(activity, forKey: .activity) + case let .queued(list): try fields.encode(list, forKey: .queued) case .closed: break } } @@ -714,29 +876,35 @@ struct AidenBotSessionEvent: Codable, Equatable, Sendable { } private enum PayloadKeys: String, CodingKey { - case session, text, entry, question, approval + case session, text, entry, question, approval, activity, queued } } /// `POST /bots/{botId}/messages` body. struct AidenBotMessageRequest: Codable, Equatable, Sendable { let text: String + /// Revision 30 (`bot-activity-v1`): Steer joins the running turn; Queue runs after it. + let whenBusy: AidenStreamInputMode? - init(text: String) throws { + init(text: String, whenBusy: AidenStreamInputMode? = nil) throws { guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, text.unicodeScalars.count <= AidenBotSessionWire.maxMessageLength else { throw AidenBotContractError.invalidField("text") } self.text = text + self.whenBusy = whenBusy } init(from decoder: Decoder) throws { - try AidenBotWire.requireOnlyKeys(decoder, allowed: ["text"]) + try AidenBotWire.requireOnlyKeys(decoder, allowed: ["text", "whenBusy"]) let values = try decoder.container(keyedBy: CodingKeys.self) - try self.init(text: try values.decode(String.self, forKey: .text)) + try self.init( + text: try values.decode(String.self, forKey: .text), + whenBusy: try values.decodeIfPresent(AidenStreamInputMode.self, forKey: .whenBusy) + ) } - private enum CodingKeys: String, CodingKey { case text } + private enum CodingKeys: String, CodingKey { case text, whenBusy } } /// The empty `{}` body of resume, dismiss, and stop. diff --git a/ios/AidenOnTheGo/Models/AidenInstallation.swift b/ios/AidenOnTheGo/Models/AidenInstallation.swift index b0fecf1d5..29c2fcde0 100644 --- a/ios/AidenOnTheGo/Models/AidenInstallation.swift +++ b/ios/AidenOnTheGo/Models/AidenInstallation.swift @@ -17,9 +17,17 @@ struct AidenInstallation: Codable, Identifiable, Equatable, Sendable { var serverCapabilities: [AidenRemoteCapability]? let createdAt: Date var lastConnectedAt: Date? + /// The Mac announced `bot-activity-v1` when it last answered `/server` (R21). + /// With the persisted `bot:activity` grant it stands in until `/server` answers. + var botActivityAnnounced: Bool var id: String { instanceId } + /// What this pairing last knew of Bot activity: announced, and granted (R21). + var cachedBotActivity: Bool { + botActivityAnnounced && hasNegotiatedAccess(to: .botActivity) + } + var isBotsEligible: Bool { hasNegotiatedAccess(to: .botRead) } @@ -54,6 +62,7 @@ struct AidenInstallation: Codable, Identifiable, Equatable, Sendable { serverCapabilities = nil self.createdAt = createdAt self.lastConnectedAt = lastConnectedAt + botActivityAnnounced = false } init(from decoder: Decoder) throws { @@ -97,6 +106,8 @@ struct AidenInstallation: Codable, Identifiable, Equatable, Sendable { } createdAt = try values.decode(Date.self, forKey: .createdAt) lastConnectedAt = try values.decodeIfPresent(Date.self, forKey: .lastConnectedAt) + // Absent in pairings saved before revision 30. + botActivityAnnounced = try values.decodeIfPresent(Bool.self, forKey: .botActivityAnnounced) ?? false try Self.requireIdentifier(instanceId, forKey: .instanceId, in: values) try Self.requireIdentifier(deviceId, forKey: .deviceId, in: values) @@ -137,11 +148,13 @@ struct AidenInstallation: Codable, Identifiable, Equatable, Sendable { try values.encodeIfPresent(serverCapabilities, forKey: .serverCapabilities) try values.encode(createdAt, forKey: .createdAt) try values.encodeIfPresent(lastConnectedAt, forKey: .lastConnectedAt) + try values.encode(botActivityAnnounced, forKey: .botActivityAnnounced) } private enum CodingKeys: String, CodingKey { case instanceId, deviceId, name, endpoint, serverSpkiSha256, pairingTrust, credentialScope case capabilities, deviceCapabilities, serverCapabilities, createdAt, lastConnectedAt + case botActivityAnnounced } private static func makeCredentialScope(instanceId: String, deviceId: String) -> String { @@ -234,6 +247,7 @@ final class AidenInstallationStore { !refreshedDeviceGrants.contains($0) } installation.serverCapabilities = validatedServer.serverCapabilities + installation.botActivityAnnounced = validatedServer.announcesBotActivity } // The scoped credential write must succeed before the installation is @@ -288,6 +302,7 @@ final class AidenInstallationStore { !refreshedDeviceGrants.contains($0) } installations[index].serverCapabilities = server.serverCapabilities + installations[index].botActivityAnnounced = server.announcesBotActivity installations[index].lastConnectedAt = connectedAt installations.sort(by: Self.sortInstallations) do { @@ -320,7 +335,7 @@ final class AidenInstallationStore { } let progress = Set([ AidenRemoteCapability.tasksRead, .agentsRead, .questionsRespond, .skillsInvoke, - .runsObserve, .runsControl, .simulatorsMobile, .botCards, + .runsObserve, .runsControl, .simulatorsMobile, .botCards, .botActivity, ]) let existingNonProgress = Set(installations[index].deviceCapabilities).subtracting(progress) guard existingNonProgress == Set(capabilities).subtracting(progress) else { @@ -329,6 +344,7 @@ final class AidenInstallationStore { let previousInstallations = installations installations[index].deviceCapabilities = capabilities installations[index].serverCapabilities = supported + installations[index].botActivityAnnounced = server.announcesBotActivity installations[index].name = server.name installations[index].lastConnectedAt = connectedAt installations.sort(by: Self.sortInstallations) diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift index 3aa370894..1cf6d8e69 100644 --- a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift +++ b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift @@ -100,6 +100,9 @@ struct AidenServer: Codable, Equatable, Sendable { /// Contract revision 25: phones may view and control the Mac's shared /// iOS Simulators after negotiating `simulators:mobile`. static let mobileSimulatorsFeature = "mobile-simulators-v1" + /// Contract revision 30: the status line, Steer and Queue, Queued bubbles + /// and Chat activity, after negotiating `bot:activity`. + static let botActivityFeature = "bot-activity-v1" /// Contract revision 28: assistant messages may carry `visuals`, each /// shown as the Mac's snapshot at its tool step. The field is additive, so /// the transcript renders visuals whenever a message carries them. @@ -298,6 +301,22 @@ struct AidenServer: Codable, Equatable, Sendable { serverCapabilities?.contains(.botCards) == true } + /// The Mac offers `bot:activity` (revision 30), so this device may ask for it. + var offersBotActivity: Bool { + serverCapabilities?.contains(.botActivity) == true + } + + /// The Mac announces `bot-activity-v1`, whatever this device holds. + var announcesBotActivity: Bool { + features.contains(Self.botActivityFeature) + } + + /// The Mac announces `bot-activity-v1` and this device holds `bot:activity`: + /// show the status line, Steer and Queue, Queued bubbles and Chat activity. + var supportsBotActivity: Bool { + announcesBotActivity && capabilities.contains(.botActivity) + } + /// This device both may and did negotiate foreign-run observation. var canObserveForeignRuns: Bool { supportsPhoneRunControl && capabilities.contains(.runsObserve) @@ -988,7 +1007,7 @@ final class AidenRemoteClient: @unchecked Sendable { ) async throws -> [AidenRemoteCapability] { let allowed = Set([ AidenRemoteCapability.tasksRead, .agentsRead, .questionsRespond, .skillsInvoke, - .runsObserve, .runsControl, .simulatorsMobile, .botCards, + .runsObserve, .runsControl, .simulatorsMobile, .botCards, .botActivity, ]) guard !accepts.isEmpty, Set(accepts).count == accepts.count, diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift b/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift index 6881d9bbc..25d012c6c 100644 --- a/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift +++ b/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift @@ -659,15 +659,22 @@ struct AidenRemoteCapability: RawRepresentable, Codable, Hashable, Sendable { /// Contract revision 26 adds the phone simulator viewer grant. /// Contract revision 27 adds `bot:cards`, which only opts the device into /// the memory and routine proposal session entries. + /// Contract revision 30 adds `bot:activity`, which only opts the device into + /// the activity and queued session frames and Bot detail `chatActivity`. static let phoneNegotiable: [Self] = v1Known + [ Self(rawValue: "runs:observe"), Self(rawValue: "runs:control"), Self(rawValue: "simulators:mobile"), Self(rawValue: "bot:cards"), + Self(rawValue: "bot:activity"), ] /// Revision 27: the device renders `memory_update` and `routine_proposal` /// Bot session entries. It grants no authority. static let botCards = Self(rawValue: "bot:cards") + /// Revision 30: the device receives activity and queued frames and Bot + /// detail `chatActivity`. It grants no authority. + static let botActivity = Self(rawValue: "bot:activity") + init(from decoder: Decoder) throws { let value = try decoder.singleValueContainer().decode(String.self) guard !value.isEmpty, value.unicodeScalars.count <= AidenRemoteProtocol.maxEventTypeLength else { diff --git a/ios/AidenOnTheGoTests/AidenBotContractTests.swift b/ios/AidenOnTheGoTests/AidenBotContractTests.swift index d5367b07f..06327108e 100644 --- a/ios/AidenOnTheGoTests/AidenBotContractTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotContractTests.swift @@ -1997,6 +1997,41 @@ final class AidenBotContractTests: XCTestCase { ).supportsBotCards) } + /// Revision 30: `bot:activity` is negotiated like `bot:cards`, and the phone + /// shows the activity UI only with both the Mac's token and this device's grant. + func testBotActivityNeedsTheMacsFeatureAndThisDevicesGrant() throws { + let response = try AidenRemoteJSONDecoder.decode( + AidenRemoteDeviceCapabilitiesUpdateResponse.self, + from: Data(#"{"capabilities":["server:read","bot:read","bot:activity"]}"#.utf8) + ) + XCTAssertTrue(response.capabilities.contains(.botActivity)) + + let server = try XCTUnwrap(try sharedFixtureObject()["server"] as? [String: Any]) + let features = try XCTUnwrap(server["features"] as? [String]) + let granted = try XCTUnwrap(server["capabilities"] as? [String]) + let supported = try XCTUnwrap(server["serverCapabilities"] as? [String]) + func decoded(feature: Bool, offered: Bool, grant: Bool) throws -> AidenServer { + var object = server + object["features"] = feature ? features + ["bot-activity-v1"] : features + object["serverCapabilities"] = offered ? supported + ["bot:activity"] : supported + object["capabilities"] = grant ? granted + ["bot:activity"] : granted + return try AidenRemoteJSONDecoder.decode(AidenServer.self, from: JSONSerialization.data(withJSONObject: object)) + } + + let neither = try decoded(feature: false, offered: false, grant: false) + XCTAssertFalse(neither.offersBotActivity, "an older Mac does not offer the opt-in") + XCTAssertFalse(neither.supportsBotActivity) + let featureOnly = try decoded(feature: true, offered: true, grant: false) + XCTAssertTrue(featureOnly.offersBotActivity, "the phone may ask for it") + XCTAssertFalse(featureOnly.supportsBotActivity, "but has not been granted it yet") + XCTAssertFalse(try decoded(feature: false, offered: true, grant: true).supportsBotActivity, "a grant without the token") + XCTAssertTrue(try decoded(feature: true, offered: true, grant: true).supportsBotActivity) + XCTAssertFalse( + try decoded(feature: true, offered: false, grant: false).offersBotActivity, + "the token alone is no offer to ask for the grant" + ) + } + func testRevision27ShapesFailClosedLikeTheHostParsers() throws { let raw = try sharedFixtureObject() let memory = try XCTUnwrap(raw["botMemory"] as? [String: Any]) diff --git a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift index 0a39827e4..a74fb80b7 100644 --- a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift @@ -1,7 +1,39 @@ +import SwiftUI +import UIKit import XCTest @testable import AidenOnTheGo final class AidenBotHomeProfileTests: XCTestCase { + // MARK: Files + + /// Every view in `view`'s tree, depth first. + private func descendants(of view: UIView) -> [UIView] { + view.subviews + view.subviews.flatMap { descendants(of: $0) } + } + + @MainActor + func testFilesShowSkeletonRowsNotASpinnerWhileTheFirstReadIsOnTheWay() throws { + for reduceMotion in [false, true] { + let host = UIHostingController(rootView: List { + Section("Files") { AidenBotFilesLoadingRows() } + } + .environment(\.aidenReduceMotion, reduceMotion)) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.2)) + + let views = descendants(of: host.view) + XCTAssertFalse(views.contains { $0 is UIActivityIndicatorView }, "no spinner (Reduce Motion \(reduceMotion))") + // The rows stand where the files will be: several list rows, not one centred control. + let rows = views.filter { String(describing: type(of: $0)).contains("ListCollectionViewCell") } + XCTAssertGreaterThanOrEqual(rows.count, 3, "skeleton rows (Reduce Motion \(reduceMotion))") + window.isHidden = true + } + } + // MARK: Fixtures private func summary( @@ -469,3 +501,778 @@ final class AidenBotHomeProfileTests: XCTestCase { XCTAssertThrowsError(try draft.accessUpdate(catalog: catalog)) } } + +private extension AidenBotCreateFlow { + /// The failure Bots home shows while the tests' usual pairing (Mac A, this phone) is in use. + var failure: Failure? { self.failure(instanceId: "mac_a", deviceId: "device_1") } +} + +/// R15: New Bot, Profile and Advanced apply at once, never wait on the +/// connection, and roll back with an error when the Mac doesn't take a change. +@MainActor +final class AidenBotInstantEditTests: XCTestCase { + private struct Refused: Error {} + + private func shared() throws -> [String: Any] { + let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "contract", withExtension: "json")) + return try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + } + + private func detail( + id: String = "bot_fixture_01", + revision: String = "bot_revision_7", + name: String = "Scout", + purpose: String? = nil, + createdAt: Date? = nil + ) throws -> AidenBotDetail { + var object = try XCTUnwrap(try shared()["botDetail"] as? [String: Any]) + object["id"] = id + object["revision"] = revision + object["name"] = name + if let purpose { object["purpose"] = purpose } + if let createdAt { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + object["createdAt"] = formatter.string(from: createdAt) + object["updatedAt"] = formatter.string(from: createdAt) + } + var access = try XCTUnwrap(object["access"] as? [String: Any]) + access["botId"] = id + object["access"] = access + return try AidenRemoteJSONDecoder.decode(AidenBotDetail.self, from: JSONSerialization.data(withJSONObject: object)) + } + + /// The fixture catalog, or the same catalog at another revision (the Mac changed something unrelated). + private func catalog(revision: String? = nil) throws -> AidenBotCapabilityCatalog { + var object = try XCTUnwrap(try shared()["botCapabilityCatalog"] as? [String: Any]) + if let revision { object["revision"] = revision } + return try AidenRemoteJSONDecoder.decode( + AidenBotCapabilityCatalog.self, + from: JSONSerialization.data(withJSONObject: object) + ) + } + + private func conflict() throws -> Error { + let body = try AidenRemoteJSONDecoder.decode( + AidenRemoteErrorEnvelope.Body.self, + from: Data(#"{"code":"revision_conflict","message":"changed","requestId":"req_test_1","retryable":false}"#.utf8) + ) + return AidenRemoteClientError.server(statusCode: 409, body: body) + } + + // MARK: Profile + + func testProfileEditsGoInOrderOnTheNewestRevision() async throws { + var sent: [(String?, String)] = [] + let saver = AidenBotIdentitySaver( + send: { patch, revision in + sent.append((patch.name, revision)) + return try self.detail(revision: "rev_\(sent.count + 7)", name: patch.name ?? "Scout") + }, + refresh: { try self.detail() } + ) + saver.adopt(try detail()) + let (one, two) = (try AidenBotIdentityPatch(name: "Scout II"), try AidenBotIdentityPatch(name: "Scout III")) + async let first = saver.save(one) + async let second = saver.save(two) + let outcomes = await [first, second] + XCTAssertEqual(sent.map(\.1), ["bot_revision_7", "rev_8"], "the second waits and goes on the first's revision") + XCTAssertEqual(outcomes, [.saved(try detail(revision: "rev_8", name: "Scout II")), .saved(try detail(revision: "rev_9", name: "Scout III"))]) + } + + func testAProfileEditTheMacSavedThoughItsAnswerWasLostCountsAsSaved() async throws { + let saver = AidenBotIdentitySaver( + send: { _, _ in throw URLError(.networkConnectionLost) }, + refresh: { try self.detail(revision: "bot_revision_8", name: "Scout II") } + ) + saver.adopt(try detail()) + let outcome = await saver.save(try AidenBotIdentityPatch(name: "Scout II")) + XCTAssertEqual(outcome, .saved(try detail(revision: "bot_revision_8", name: "Scout II"))) + } + + func testARefusedProfileEditRollsBackToWhatTheMacHolds() async throws { + var sent = 0 + let saver = AidenBotIdentitySaver( + send: { _, _ in sent += 1; throw try self.conflict() }, + refresh: { try self.detail(revision: "bot_revision_9", name: "Scout (Mac)") } + ) + saver.adopt(try detail()) + let outcome = await saver.save(try AidenBotIdentityPatch(name: "Scout II")) + XCTAssertEqual(outcome, .failed(authoritative: try detail(revision: "bot_revision_9", name: "Scout (Mac)"))) + XCTAssertEqual(sent, 1) + + // Unreadable afterwards too: the page goes back to what it showed before. + let unreadable = AidenBotIdentitySaver(send: { _, _ in throw Refused() }, refresh: { throw Refused() }) + unreadable.adopt(try detail()) + let lost = await unreadable.save(try AidenBotIdentityPatch(name: "Scout II")) + XCTAssertEqual(lost, .failed(authoritative: nil)) + } + + func testAProfileEditWaitsForTheConnectionAndNeverSendsWithoutOne() async throws { + var sent = 0 + var asked: [Duration] = [] + let saver = AidenBotIdentitySaver( + send: { _, _ in sent += 1; return try self.detail(revision: "bot_revision_8") }, + refresh: { throw Refused() }, + waitForConnection: { hold in asked.append(hold); return false } + ) + saver.adopt(try detail()) + let outcome = await saver.save(try AidenBotIdentityPatch(name: "Scout II")) + XCTAssertEqual(outcome, .failed(authoritative: nil)) + XCTAssertEqual(sent, 0, "nothing goes out without the Mac") + XCTAssertEqual(asked, [.seconds(8)], "it waits briefly for the connection first") + } + + // MARK: Advanced + + private func operations( + connected: Bool = true, + identity: @escaping @MainActor (String, AidenBotIdentityPatch) async throws -> AidenBotDetail, + readBot: @escaping @MainActor () async throws -> AidenBotDetail + ) throws -> AidenBotAdvancedSaveOperations { + let catalog = try catalog() + return AidenBotAdvancedSaveOperations( + waitForConnection: { _ in connected }, + updateIdentity: identity, + updateAccess: { _, _ in }, + readBot: readBot, + readCatalog: { catalog } + ) + } + + func testAnAdvancedSaveSendsTheEditOnTheRevisionItWasMadeOn() async throws { + let baseline = try detail() + var draft = try XCTUnwrap(AidenBotEditorDraft(detail: baseline, catalog: try catalog())) + draft.name = "Scout II" + var revisions: [String] = [] + let outcome = await aidenBotAdvancedSave( + draft: draft, baseline: baseline, catalog: try catalog(), + operations: try operations(identity: { revision, patch in + revisions.append(revision) + return try self.detail(revision: "bot_revision_8", name: patch.name ?? "") + }, readBot: { throw Refused() }) + ) + XCTAssertEqual(revisions, ["bot_revision_7"]) + XCTAssertEqual(outcome, .saved(try detail(revision: "bot_revision_8", name: "Scout II"))) + } + + func testAnAdvancedSaveWhoseAnswerWasLostButLandedIsSaved() async throws { + let baseline = try detail() + var draft = try XCTUnwrap(AidenBotEditorDraft(detail: baseline, catalog: try catalog())) + draft.name = "Scout II" + let outcome = await aidenBotAdvancedSave( + draft: draft, baseline: baseline, catalog: try catalog(), + operations: try operations( + identity: { _, _ in throw URLError(.networkConnectionLost) }, + readBot: { try self.detail(revision: "bot_revision_8", name: "Scout II") } + ) + ) + XCTAssertEqual(outcome, .saved(try detail(revision: "bot_revision_8", name: "Scout II"))) + } + + func testAnAdvancedSaveThatDidNotStickSaysSoBehindTheClosedPage() async throws { + let baseline = try detail() + var draft = try XCTUnwrap(AidenBotEditorDraft(detail: baseline, catalog: try catalog())) + draft.name = "Scout II" + let changed = await aidenBotAdvancedSave( + draft: draft, baseline: baseline, catalog: try catalog(), + operations: try operations( + identity: { _, _ in throw try self.conflict() }, + readBot: { try self.detail(revision: "bot_revision_9", name: "Scout") } + ) + ) + XCTAssertEqual(changed, .failed(AidenBotAdvancedCopy.changedOnMac)) + + var sent = 0 + let offline = await aidenBotAdvancedSave( + draft: draft, baseline: baseline, catalog: try catalog(), + operations: try operations(connected: false, identity: { _, _ in + sent += 1 + return try self.detail() + }, readBot: { try self.detail() }) + ) + XCTAssertEqual(offline, .failed(AidenBotAdvancedCopy.unreachable)) + XCTAssertEqual(sent, 0) + } + + // MARK: New Bot + + /// A scripted Mac for New Bot, recording what reached it. + private final class CreateMac { + var creates: [(request: AidenBotCreateRequest, key: UUID)] = [] + var chats: [UUID] = [] + var createErrors: [Error] = [] + var chatErrors: [Error] = [] + var hold: CheckedContinuation? + var holds = false + /// Runs as each create reaches the Mac. + var onCreate: (@MainActor () async -> Void)? + } + + private func createOperations( + _ mac: CreateMac, + connected: Bool = true, + durable: Bool = true, + catalogRevision: String? = nil, + instanceId: String = "mac_a", + deviceId: String = "device_1" + ) throws -> AidenBotCreateOperations { + let catalog = try catalog(revision: catalogRevision) + return AidenBotCreateOperations( + instanceId: instanceId, + deviceId: deviceId, + usesDurableSession: durable, + waitForConnection: { _ in connected }, + catalog: { catalog }, + create: { request, key in + mac.creates.append((request, key)) + await mac.onCreate?() + if mac.holds { await withCheckedContinuation { mac.hold = $0 } } + if !mac.createErrors.isEmpty { throw mac.createErrors.removeFirst() } + return try self.detail(id: "bot_new_01", revision: "bot_revision_1", name: request.name) + }, + createChat: { _, _, key in + mac.chats.append(key) + if !mac.chatErrors.isEmpty { throw mac.chatErrors.removeFirst() } + }, + readBot: { id in try self.detail(id: id, revision: "bot_revision_2", name: "Planner") } + ) + } + + private let input = AidenBotCreateInput(name: " Planner ", purpose: "Plans my week", draft: nil) + + private var roots: [URL] = [] + + override func tearDown() { + for root in roots { try? FileManager.default.removeItem(at: root) } + roots = [] + super.tearDown() + } + + /// A store on its own folder; a second store on the same folder is the next launch. + private func createRoot() -> URL { + let root = FileManager.default.temporaryDirectory.appending(path: "bot-create-\(UUID().uuidString)", directoryHint: .isDirectory) + roots.append(root) + return root + } + + /// A New Bot flow that keeps what waits under `root` (a fresh folder by default). + @MainActor + private func createFlow(_ root: URL? = nil, now: @escaping @Sendable () -> Date = { Date() }) -> AidenBotCreateFlow { + AidenBotCreateFlow(store: AidenBotOutboxStore(root: root ?? createRoot(), now: now), now: now) + } + + func testCreateShowsTheBotAtOnceAndOpensItOnceTheMacHasIt() async throws { + let mac = CreateMac() + mac.holds = true + let flow = createFlow() + var created: [AidenBotDetail] = [] + let operations = try createOperations(mac) + let task = Task { await flow.submit(self.input, operations: operations) { created.append($0) } } + let deadline = Date().addingTimeInterval(5) + while mac.hold == nil { + guard Date() < deadline else { return XCTFail("the create never went out") } + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertEqual(flow.pending?.name, "Planner", "the row shows before the Mac answers") + mac.hold?.resume() + await task.value + XCTAssertNil(flow.pending) + XCTAssertNil(flow.failure) + XCTAssertEqual(created.map(\.revision), ["bot_revision_2"], "the Mac's own Bot opens") + let sent = try XCTUnwrap(mac.creates.first?.request) + XCTAssertEqual(sent.name, "Planner") + XCTAssertNotNil(sent.access, "no model picked: the Mac's recommended one") + } + + func testAFailedCreateOffersTryAgainWithTheSameBotAndNeverMakesTwo() async throws { + let mac = CreateMac() + let flow = createFlow() + var created = 0 + + // Lost on the way: Try Again replays the same key. + mac.createErrors = [URLError(.networkConnectionLost)] + await flow.submit(input, operations: try createOperations(mac)) { _ in created += 1 } + let failure = try XCTUnwrap(flow.failure) + XCTAssertEqual(failure.input, input, "Try Again brings New Bot back as it was") + XCTAssertNil(flow.pending, "the row goes") + await flow.submit(failure.input, operations: try createOperations(mac)) { _ in created += 1 } + XCTAssertEqual(mac.creates.count, 2) + XCTAssertEqual(mac.creates[0].key, mac.creates[1].key, "the Mac may have made it: same key") + XCTAssertEqual(created, 1) + + // Refused outright: the next try is a new request. + mac.createErrors = [try conflict()] + await flow.submit(input, operations: try createOperations(mac)) { _ in created += 1 } + await flow.submit(input, operations: try createOperations(mac)) { _ in created += 1 } + XCTAssertNotEqual(mac.creates[2].key, mac.creates[3].key) + } + + func testTryAgainAfterALostCreateReplaysTheSameRequestThroughACatalogChange() async throws { + let mac = CreateMac() + mac.createErrors = [URLError(.networkConnectionLost)] + let flow = createFlow() + var created = 0 + + // The Create answer is lost; while the person is away the Mac's catalog moves on. + await flow.submit(input, operations: try createOperations(mac, catalogRevision: "catalog_rev_4")) { _ in created += 1 } + let failure = try XCTUnwrap(flow.failure) + await flow.submit(failure.input, operations: try createOperations(mac, catalogRevision: "catalog_rev_5")) { _ in created += 1 } + + XCTAssertEqual(mac.creates.count, 2) + XCTAssertEqual(mac.creates[1].request, mac.creates[0].request, "Try Again sends the frozen body, not one built from the new catalog") + XCTAssertEqual(mac.creates[1].key, mac.creates[0].key, "the Mac may have made the Bot: same Idempotency-Key") + XCTAssertEqual(created, 1, "one Bot") + + // Lost again, then the person renames it: that is a new ask, on a new key. + mac.createErrors = [URLError(.networkConnectionLost)] + await flow.submit(input, operations: try createOperations(mac, catalogRevision: "catalog_rev_5")) { _ in created += 1 } + var renamed = try XCTUnwrap(flow.failure).input + renamed.name = "Weekly Planner" + await flow.submit(renamed, operations: try createOperations(mac, catalogRevision: "catalog_rev_5")) { _ in created += 1 } + XCTAssertEqual(mac.creates.count, 4) + XCTAssertNotEqual(mac.creates[3].key, mac.creates[2].key) + XCTAssertEqual(mac.creates[3].request.name, "Weekly Planner") + } + + func testABotMadeBeforeItsChatFailedIsNotMadeAgain() async throws { + let mac = CreateMac() + mac.chatErrors = [URLError(.timedOut)] + let flow = createFlow() + await flow.submit(input, operations: try createOperations(mac, durable: false)) { _ in } + XCTAssertNotNil(flow.failure) + await flow.submit(input, operations: try createOperations(mac, durable: false)) { _ in } + XCTAssertEqual(mac.creates.count, 1, "the Bot already made is kept") + XCTAssertEqual(mac.chats.count, 2) + XCTAssertEqual(mac.chats[0], mac.chats[1], "its chat retries under the same key") + XCTAssertNil(flow.failure) + } + + @MainActor + func testACreateCutOffByARelaunchComesBackWithTryAgainAndMakesOneBot() async throws { + let root = createRoot() + let mac = CreateMac() + mac.holds = true + let first = createFlow(root) + let operations = try createOperations(mac) + let task = Task { await first.submit(self.input, operations: operations) { _ in } } + let deadline = Date().addingTimeInterval(5) + while mac.hold == nil { + guard Date() < deadline else { return XCTFail("the create never went out") } + try await Task.sleep(for: .milliseconds(5)) + } + // While this launch is still sending it, another Bots home never shows it as cut off. + let beside = createFlow(root) + await beside.restore(instanceId: "mac_a", deviceId: "device_1") + XCTAssertNil(beside.failure) + + // The app goes away with the request on its way to the Mac. + task.cancel() + await task.value + mac.hold?.resume() + + // The next launch: Bots home shows the Bot as not made, with its answers. + let relaunched = createFlow(root) + await relaunched.restore(instanceId: "mac_a", deviceId: "device_1") + let failure = try XCTUnwrap(relaunched.failure, "the Bot on its way is never silently gone") + XCTAssertEqual(failure.input, input) + XCTAssertTrue(failure.replays, "it may have reached the Mac: Try Again sends it again as it was") + XCTAssertEqual(failure.message, AidenBotCreateFlow.interrupted) + + // A Try Again that can't reach the Mac still offers to send it again as it was. + await relaunched.submit(failure.input, operations: try createOperations(mac, connected: false)) { _ in } + XCTAssertEqual(relaunched.failure?.replays, true) + XCTAssertEqual(mac.creates.count, 1) + + // Try Again: the same request on the same key, so the Mac makes one Bot. + mac.holds = false + var created: [String] = [] + await relaunched.submit(failure.input, operations: try createOperations(mac, catalogRevision: "catalog_rev_9")) { created.append($0.id) } + XCTAssertEqual(mac.creates.count, 2) + XCTAssertEqual(mac.creates[1].key, mac.creates[0].key) + XCTAssertEqual(mac.creates[1].request, mac.creates[0].request) + XCTAssertEqual(created, ["bot_new_01"]) + XCTAssertNil(relaunched.failure) + + // Made: nothing waits for a later launch. + let after = createFlow(root) + await after.restore(instanceId: "mac_a", deviceId: "device_1") + XCTAssertNil(after.failure) + } + + @MainActor + func testAnUnsentCreateComesBackForItsOwnPairingOnlyUntilItIsLetGo() async throws { + let root = createRoot() + let mac = CreateMac() + let first = createFlow(root) + await first.submit(input, operations: try createOperations(mac, connected: false)) { _ in } + XCTAssertEqual(mac.creates.count, 0) + + let otherPhone = createFlow(root) + await otherPhone.restore(instanceId: "mac_a", deviceId: "device_2") + XCTAssertNil(otherPhone.failure(instanceId: "mac_a", deviceId: "device_2"), "another pairing never sees it") + + let relaunched = createFlow(root) + await relaunched.restore(instanceId: "mac_a", deviceId: "device_1") + let failure = try XCTUnwrap(relaunched.failure) + XCTAssertEqual(failure.input, input) + XCTAssertFalse(failure.replays, "nothing reached the Mac: Try Again reopens New Bot as it was") + + // OK lets it go: the launch after has nothing. + _ = await relaunched.discard(failure)?.value + XCTAssertNil(relaunched.failure) + let later = createFlow(root) + await later.restore(instanceId: "mac_a", deviceId: "device_1") + XCTAssertNil(later.failure) + } + + /// Holds the store's next directory write, and with it every later call on that store, + /// or refuses every write, as a full disk does. + private final class HoldingFileManager: FileManager, @unchecked Sendable { + private let lock = NSLock() + private var armed = false + private var holding = false + private var refusing = false + private let gate = DispatchSemaphore(value: 0) + + func arm() { lock.withLock { armed = true } } + var isHolding: Bool { lock.withLock { holding } } + func letGo() { gate.signal() } + func refuseWrites() { lock.withLock { refusing = true } } + + override func createDirectory( + at url: URL, + withIntermediateDirectories createIntermediates: Bool, + attributes: [FileAttributeKey: Any]? = nil + ) throws { + let (holds, refuses) = lock.withLock { () -> (Bool, Bool) in + guard armed else { return (false, refusing) } + armed = false + holding = true + return (true, refusing) + } + if refuses { throw CocoaError(.fileWriteOutOfSpace) } + if holds { gate.wait() } + try super.createDirectory(at: url, withIntermediateDirectories: createIntermediates, attributes: attributes) + } + } + + @MainActor + func testMacAsRestoreLandingAfterASwitchToMacBShowsNothingOnBAndSendsNothingThere() async throws { + let root = createRoot() + let macA = CreateMac() + let macB = CreateMac() + // Mac A's create went out and the app closed before its answer. + macA.createErrors = [URLError(.networkConnectionLost)] + await createFlow(root).submit(input, operations: try createOperations(macA)) { _ in } + + // The next launch looks for it while Mac A is in use; the look is held on the disk. + let files = HoldingFileManager() + let store = AidenBotOutboxStore(root: root, fileManager: files) + let relaunched = AidenBotCreateFlow(store: store) + let other = AidenBotPendingCreate(input: input, frozen: nil, createdAt: Date()) + files.arm() + let holder = Task.detached { await store.savePendingCreate(other, instanceId: "mac_z", deviceId: "device_9") } + let deadline = Date().addingTimeInterval(5) + while !files.isHolding { + guard Date() < deadline else { return XCTFail("the store was never held") } + try await Task.sleep(for: .milliseconds(5)) + } + var macAInUse = true + let look = Task { await relaunched.restore(instanceId: "mac_a", deviceId: "device_1") { macAInUse } } + await Task.yield() + // A link switches the phone to Mac B while the look is on its way. + macAInUse = false + files.letGo() + await look.value + await holder.value + + XCTAssertNil(relaunched.failure(instanceId: "mac_b", deviceId: "device_1"), "Mac B's Bots home never shows Mac A's create") + XCTAssertNil(relaunched.failure, "nor is it published for Mac A while Mac B is in use") + XCTAssertEqual(macB.creates.count, 0, "nothing of Mac A's reaches Mac B") + + // A look whose Bots home task was cancelled shows nothing either. + let cancelled = Task { await relaunched.restore(instanceId: "mac_a", deviceId: "device_1") } + cancelled.cancel() + await cancelled.value + XCTAssertNil(relaunched.failure) + + // Back on Mac A it comes back, and Try Again sends it there on the key Mac A may hold. + await relaunched.restore(instanceId: "mac_a", deviceId: "device_1") + let failure = try XCTUnwrap(relaunched.failure, "it waited for Mac A") + XCTAssertEqual(relaunched.retry(failure, instanceId: "mac_a", deviceId: "device_1"), .send(input)) + await relaunched.submit(failure.input, operations: try createOperations(macA), retrying: failure) { _ in } + XCTAssertEqual(macA.creates.count, 2) + XCTAssertEqual(macA.creates[1].key, macA.creates[0].key) + XCTAssertEqual(macB.creates.count, 0) + } + + @MainActor + func testTryAgainOnMacAsFailedCreateWhileMacBIsInUseNeverSendsItToB() async throws { + let macA = CreateMac() + let macB = CreateMac() + let flow = createFlow() + // Mac A loses the answer; then a link switches the phone to Mac B with A's failure kept. + macA.createErrors = [URLError(.networkConnectionLost)] + await flow.submit(input, operations: try createOperations(macA)) { _ in } + let failure = try XCTUnwrap(flow.failure) + XCTAssertNil(flow.failure(instanceId: "mac_b", deviceId: "device_1"), "Mac B's Bots home never offers it") + + // Try Again, or Create in the New Bot it reopened, while Mac B is in use: nothing reaches B. + XCTAssertEqual(flow.retry(failure, instanceId: "mac_b", deviceId: "device_1"), .none) + await flow.submit(failure.input, operations: try createOperations(macB, instanceId: "mac_b"), retrying: failure) { _ in } + XCTAssertEqual(macB.creates.count, 0, "Mac A's answers never become a Bot on Mac B") + XCTAssertNil(flow.pending) + XCTAssertEqual(flow.failure, failure, "they wait for Mac A") + + // Back on Mac A, Try Again reopens New Bot and its Create goes to A on A's key. + XCTAssertEqual(flow.retry(failure, instanceId: "mac_a", deviceId: "device_1"), .reopen(failure)) + XCTAssertNil(flow.failure) + await flow.submit(failure.input, operations: try createOperations(macA), retrying: failure) { _ in } + XCTAssertEqual(macA.creates.count, 2) + XCTAssertEqual(macA.creates[1].key, macA.creates[0].key) + XCTAssertEqual(macB.creates.count, 0) + } + + @MainActor + func testACreateIsOnDiskBeforeItGoesOutAndNothingGoesOutWhenItCannotBe() async throws { + let root = createRoot() + let mac = CreateMac() + var onDisk: [AidenBotPendingCreate.Frozen?] = [] + // What a launch after the phone died right then would find, read as each request leaves. + mac.onCreate = { + onDisk.append(await AidenBotOutboxStore(root: root).pendingCreate(instanceId: "mac_a", deviceId: "device_1")?.frozen) + } + + // Create, Try Again after its answer was lost, and a relaunch's Try Again. + mac.createErrors = [URLError(.networkConnectionLost), URLError(.networkConnectionLost)] + let flow = createFlow(root) + await flow.submit(input, operations: try createOperations(mac)) { _ in } + let failed = try XCTUnwrap(flow.failure) + await flow.submit(failed.input, operations: try createOperations(mac), retrying: failed) { _ in } + let relaunched = createFlow(root) + await relaunched.restore(instanceId: "mac_a", deviceId: "device_1") + let restored = try XCTUnwrap(relaunched.failure) + await relaunched.submit(restored.input, operations: try createOperations(mac), retrying: restored) { _ in } + + XCTAssertEqual(mac.creates.count, 3) + XCTAssertEqual(onDisk.count, 3) + for (sent, kept) in zip(mac.creates, onDisk) { + XCTAssertEqual(kept?.request, sent.request, "the request on disk is the one going out") + XCTAssertEqual(kept?.key, sent.key, "with its key") + } + + // A phone that can't write it sends nothing, and says so. + let blocked = createRoot() + try Data().write(to: blocked) // a file where the store's folder belongs + let refused = CreateMac() + let cannotKeep = createFlow(blocked) + await cannotKeep.submit(input, operations: try createOperations(refused)) { _ in } + XCTAssertEqual(refused.creates.count, 0, "a create that couldn't be kept never reaches the Mac") + XCTAssertEqual(cannotKeep.failure?.message, AidenBotCreateFlow.notKept) + + // Nor does a relaunch's Try Again whose request can't be written again. + let lostRoot = createRoot() + let lost = CreateMac() + lost.createErrors = [URLError(.networkConnectionLost)] + await createFlow(lostRoot).submit(input, operations: try createOperations(lost)) { _ in } + let files = HoldingFileManager() + files.refuseWrites() + let full = AidenBotCreateFlow(store: AidenBotOutboxStore(root: lostRoot, fileManager: files)) + await full.restore(instanceId: "mac_a", deviceId: "device_1") + let again = try XCTUnwrap(full.failure) + XCTAssertTrue(again.replays) + await full.submit(again.input, operations: try createOperations(lost), retrying: again) { _ in } + XCTAssertEqual(lost.creates.count, 1, "the replay waits until its request is on disk again") + XCTAssertEqual(full.failure?.message, AidenBotCreateFlow.notKept) + XCTAssertEqual(full.failure?.replays, true, "and Try Again still sends it as it was") + } + + @MainActor + func testTryAgainWhileAnotherMacsBotIsOnItsWayKeepsItsAnswersUntilThatOneLands() async throws { + let root = createRoot() + // Mac B's create went out at the last launch and its answer was lost. + let macB = CreateMac() + macB.createErrors = [URLError(.networkConnectionLost)] + await createFlow(root).submit(input, operations: try createOperations(macB, instanceId: "mac_b")) { _ in } + + // This launch, a Bot on its way to Mac A is held at the Mac... + let flow = createFlow(root) + let macA = CreateMac() + macA.holds = true + var scout = input + scout.name = "Scout" + let operationsA = try createOperations(macA) + let making = Task { await flow.submit(scout, operations: operationsA) { _ in } } + let deadline = Date().addingTimeInterval(5) + while macA.hold == nil { + guard Date() < deadline else { return XCTFail("the create never went out") } + try await Task.sleep(for: .milliseconds(5)) + } + // ...when the phone moves to Mac B, whose cut-off create comes back, and Try Again is tapped. + await flow.restore(instanceId: "mac_b", deviceId: "device_1") + let failure = try XCTUnwrap(flow.failure(instanceId: "mac_b", deviceId: "device_1")) + XCTAssertEqual(flow.retry(failure, instanceId: "mac_b", deviceId: "device_1"), .send(input)) + await flow.submit(input, operations: try createOperations(macB, instanceId: "mac_b"), retrying: failure) { _ in } + XCTAssertEqual(macB.creates.count, 1, "one Bot is made at a time: nothing goes out yet") + XCTAssertEqual(flow.failure(instanceId: "mac_b", deviceId: "device_1"), failure, "its answers wait as they were") + + // Once Mac A's Bot lands, Try Again sends Mac B's on the key Mac B may hold. + macA.hold?.resume() + await making.value + XCTAssertEqual(flow.retry(failure, instanceId: "mac_b", deviceId: "device_1"), .send(input)) + await flow.submit(input, operations: try createOperations(macB, instanceId: "mac_b"), retrying: failure) { _ in } + XCTAssertEqual(macB.creates.count, 2) + XCTAssertEqual(macB.creates[1].key, macB.creates[0].key) + XCTAssertEqual(macA.creates.count, 1) + } + + @MainActor + func testAPendingCreateGoesWithAccessAndWithAnUnpair() async throws { + let mac = CreateMac() + + // The Mac refuses this phone: nothing of the ask stays on disk. + let refusedRoot = createRoot() + mac.createErrors = [AidenRemoteClientError.unexpectedStatus(403)] + let refused = createFlow(refusedRoot) + await refused.submit(input, operations: try createOperations(mac)) { _ in } + XCTAssertNotNil(refused.failure) + let afterRefusal = createFlow(refusedRoot) + await afterRefusal.restore(instanceId: "mac_a", deviceId: "device_1") + XCTAssertNil(afterRefusal.failure) + + // Unpaired: the file goes, and a write still to come never brings it back. + let root = createRoot() + let store = AidenBotOutboxStore(root: root) + let flow = AidenBotCreateFlow(store: store) + await flow.submit(input, operations: try createOperations(mac, connected: false)) { _ in } + await store.purge(instanceId: "mac_a") + await flow.submit(input, operations: try createOperations(mac, connected: false)) { _ in } + let relaunched = createFlow(root) + await relaunched.restore(instanceId: "mac_a", deviceId: "device_1") + XCTAssertNil(relaunched.failure) + } + + @MainActor + func testAfterADayAMadeBotIsTheMacsButAnUnmadeOneStillComesBack() async throws { + let start = Date() + let dayLater: @Sendable () -> Date = { start.addingTimeInterval(25 * 60 * 60) } + + // Made, but its chat step was cut off: a day later the Bot is the Mac's. + let madeRoot = createRoot() + let mac = CreateMac() + mac.chatErrors = [URLError(.timedOut)] + await createFlow(madeRoot, now: { start }).submit(input, operations: try createOperations(mac, durable: false)) { _ in } + let soon = createFlow(madeRoot, now: { start }) + await soon.restore(instanceId: "mac_a", deviceId: "device_1") + XCTAssertNotNil(soon.failure, "within the day Try Again finishes it") + let made = createFlow(madeRoot, now: dayLater) + await made.restore(instanceId: "mac_a", deviceId: "device_1") + XCTAssertNil(made.failure) + + // Never made: it comes back however long it waited. + let unmadeRoot = createRoot() + await createFlow(unmadeRoot, now: { start }).submit(input, operations: try createOperations(mac, connected: false)) { _ in } + let unmade = createFlow(unmadeRoot, now: dayLater) + await unmade.restore(instanceId: "mac_a", deviceId: "device_1") + XCTAssertEqual(unmade.failure?.input, input) + } + + /// A clock a test moves on while a flow and its store read it. + private final class TestClock: @unchecked Sendable { + private let lock = NSLock() + private var current: Date + init(_ start: Date) { current = start } + var now: Date { lock.withLock { current } } + func advance(_ seconds: TimeInterval) { lock.withLock { current = current.addingTimeInterval(seconds) } } + } + + @MainActor + func testACreateTheMacMadeWhoseAnswerWasLostIsNeverSentAgainOnItsKeyADayLater() async throws { + let root = createRoot() + let start = Date() + let mac = CreateMac() + // The Mac makes the Bot, but its answer is lost on the way back; then the app closes. + mac.createErrors = [URLError(.networkConnectionLost)] + await createFlow(root, now: { start }).submit(input, operations: try createOperations(mac)) { _ in } + let first = try XCTUnwrap(mac.creates.first) + + // Relaunched 25 hours later: the Mac no longer keeps that key, and would run it again. + let dayLater: @Sendable () -> Date = { start.addingTimeInterval(25 * 60 * 60) } + let relaunched = createFlow(root, now: dayLater) + await relaunched.restore(instanceId: "mac_a", deviceId: "device_1") + let failure = try XCTUnwrap(relaunched.failure, "its answers are never silently gone") + XCTAssertEqual(failure.input, input) + XCTAssertFalse(failure.replays, "Try Again no longer sends it again as it was") + + // The same answers going out again never reuse the forgotten key. + await relaunched.submit(failure.input, operations: try createOperations(mac)) { _ in } + XCTAssertEqual(mac.creates.count, 2) + XCTAssertNotEqual(mac.creates[1].key, first.key) + } + + @MainActor + func testTryAgainPastTheMacsKeyWindowLooksForTheBotAndOtherwiseReopensNewBot() async throws { + let start = Date() + let olderTwin = AidenBotSummary(detail: try detail( + id: "bot_older_01", name: "Planner", purpose: "Plans my week", createdAt: start.addingTimeInterval(-3_600) + )) + + for listShowsIt in [true, false] { + let root = createRoot() + let clock = TestClock(start) + let mac = CreateMac() + mac.createErrors = [URLError(.networkConnectionLost)] + await createFlow(root, now: { clock.now }).submit(input, operations: try createOperations(mac)) { _ in } + let first = try XCTUnwrap(mac.creates.first) + + // Relaunched within the window, the prompt offers to send it again as it was... + clock.advance(22 * 60 * 60) + let relaunched = createFlow(root, now: { clock.now }) + await relaunched.restore(instanceId: "mac_a", deviceId: "device_1") + let failure = try XCTUnwrap(relaunched.failure) + XCTAssertTrue(failure.replays) + + // ...but it stays open past the window before Try Again. + clock.advance(2 * 60 * 60) + // The Mac's clock may run a little behind the phone's. + let made = AidenBotSummary(detail: try detail( + id: "bot_new_01", name: "Planner", purpose: "Plans my week", createdAt: start.addingTimeInterval(-60) + )) + let bots = listShowsIt ? [olderTwin, made] : [olderTwin] + let retry = relaunched.retry(failure, instanceId: "mac_a", deviceId: "device_1", bots: bots) + XCTAssertNil(relaunched.failure) + XCTAssertEqual(mac.creates.count, 1, "nothing goes out on Try Again") + + if listShowsIt { + XCTAssertEqual(retry, .settled, "the Mac made it: the failure goes quietly") + let deadline = Date().addingTimeInterval(5) + while await AidenBotOutboxStore(root: root).pendingCreate(instanceId: "mac_a", deviceId: "device_1") != nil { + guard Date() < deadline else { return XCTFail("the settled create still waits on disk") } + try await Task.sleep(for: .milliseconds(5)) + } + let after = createFlow(root, now: { clock.now }) + await after.restore(instanceId: "mac_a", deviceId: "device_1") + XCTAssertNil(after.failure, "and nothing waits for a later launch") + } else { + // A Bot of the same name made before it went out is not it: New Bot reopens with the answers. + guard case let .reopen(reopened) = retry else { return XCTFail("expected New Bot to reopen, got \(retry)") } + XCTAssertEqual(reopened.input, input) + XCTAssertFalse(reopened.replays) + // Create there is the person's new request, on a new key. + await relaunched.submit(reopened.input, operations: try createOperations(mac), retrying: reopened) { _ in } + XCTAssertEqual(mac.creates.count, 2) + XCTAssertNotEqual(mac.creates[1].key, first.key) + } + } + } + + func testACreateThatNeverReachesTheMacSaysSoAndSendsNothing() async throws { + let mac = CreateMac() + let flow = createFlow() + await flow.submit(input, operations: try createOperations(mac, connected: false)) { _ in } + XCTAssertEqual(flow.failure?.message, AidenBotCreateFlow.unreachable) + XCTAssertEqual(mac.creates.count, 0) + XCTAssertNil(flow.pending) + } +} diff --git a/ios/AidenOnTheGoTests/AidenBotMemoryTests.swift b/ios/AidenOnTheGoTests/AidenBotMemoryTests.swift index f6f029a25..da3c875da 100644 --- a/ios/AidenOnTheGoTests/AidenBotMemoryTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotMemoryTests.swift @@ -10,6 +10,8 @@ private final class FakeBotMemoryTransport: AidenBotMemoryTransport, @unchecked private var _editResults: [Result] = [] private var _readCount = 0 private var _edits: [(edit: AidenBotMemoryEdit, key: UUID)] = [] + private var _holding = false + private var _held: [CheckedContinuation] = [] private func locked(_ body: () -> T) -> T { lock.lock() @@ -21,6 +23,17 @@ private final class FakeBotMemoryTransport: AidenBotMemoryTransport, @unchecked func queueEdit(_ result: Result) { locked { _editResults.append(result) } } var readCount: Int { locked { _readCount } } var edits: [(edit: AidenBotMemoryEdit, key: UUID)] { locked { _edits } } + /// Holds each edit until `releaseEdits()`. + func holdEdits() { locked { _holding = true } } + var heldEdits: Int { locked { _held.count } } + func releaseEdits() { + let waiting = locked { () -> [CheckedContinuation] in + _holding = false + defer { _held = [] } + return _held + } + waiting.forEach { $0.resume() } + } func botMemory(botId: String) async throws -> AidenBotMemory { let next = locked { () -> Result? in @@ -36,9 +49,14 @@ private final class FakeBotMemoryTransport: AidenBotMemoryTransport, @unchecked request: AidenBotMemoryEditRequest, idempotencyKey: UUID ) async throws -> AidenBotMemory { + locked { _edits.append((request.edit, idempotencyKey)) } + if locked({ _holding }) { + await withCheckedContinuation { continuation in + locked { _held.append(continuation) } + } + } let next = locked { () -> Result? in - _edits.append((request.edit, idempotencyKey)) - return _editResults.isEmpty ? nil : _editResults.removeFirst() + _editResults.isEmpty ? nil : _editResults.removeFirst() } guard let next else { throw URLError(.notConnectedToInternet) } return try next.get() @@ -267,6 +285,79 @@ final class AidenBotMemoryTests: XCTestCase { XCTAssertEqual(model.countSummary, "Nothing yet") } + // MARK: Instant edits (R15) + + private func waitUntil(_ condition: () -> Bool, what: String) async throws { + let deadline = Date().addingTimeInterval(5) + while !condition() { + guard Date() < deadline else { return XCTFail("Timed out waiting for \(what)") } + try await Task.sleep(for: .milliseconds(5)) + } + } + + func testAnEditShowsAtOnceAndTheMacsViewReplacesIt() async throws { + let transport = FakeBotMemoryTransport() + let model = await loadedModel(transport, try fixtureMemory()) + transport.holdEdits() + transport.queueEdit(.success(try editedMemory())) + let saving = Task { await model.save(target: .user, entryId: "0f1e2d3c4b5a6978", text: "Prefers tea.") } + try await waitUntil({ transport.heldEdits == 1 }, what: "the edit") + XCTAssertEqual(model.memory?.user.entries.first?.text, "Prefers tea.", "the new text shows before the Mac answers") + transport.releaseEdits() + let failure = await saving.value + XCTAssertNil(failure) + XCTAssertEqual(model.memory, try editedMemory(), "the Mac's own view replaces it") + } + + func testADeleteGoesAtOnceAndComesBackWhenItFailsAndTheMacCantBeRead() async throws { + let transport = FakeBotMemoryTransport() + let before = try fixtureMemory() + let model = await loadedModel(transport, before) + transport.holdEdits() + transport.queueEdit(.failure(try serverError(status: 422, code: "invalid_request"))) + let deleting = Task { await model.delete(target: .memory, entryId: "a1b2c3d4e5f60718") } + try await waitUntil({ transport.heldEdits == 1 }, what: "the delete") + XCTAssertEqual(model.memory?.memory.entries.count, 0, "gone at once") + XCTAssertEqual(model.countSummary, "2 things") + transport.releaseEdits() + await deleting.value + XCTAssertEqual(model.memory, before, "no view to refetch: back to what showed before") + XCTAssertEqual(model.errorMessage, AidenBotMemoryCopy.notSaved) + } + + func testQuickEditsAreNeverRefusedAndReachTheMacInOrder() async throws { + let transport = FakeBotMemoryTransport() + let model = await loadedModel(transport, try fixtureMemory()) + transport.holdEdits() + transport.queueEdit(.success(try fixtureMemory())) + transport.queueEdit(.success(try emptyMemory())) + let first = Task { await model.delete(target: .memory, entryId: "a1b2c3d4e5f60718") } + try await waitUntil({ transport.heldEdits == 1 }, what: "the first delete") + let second = Task { await model.delete(target: .user, entryId: "0f1e2d3c4b5a6978") } + try await waitUntil({ model.memory?.user.entries.count == 1 }, what: "the second delete to show") + XCTAssertEqual(transport.edits.count, 1, "the second waits for the first") + transport.releaseEdits() + await first.value + await second.value + XCTAssertEqual(transport.edits.map(\.edit), [ + .remove(target: .memory, entryId: "a1b2c3d4e5f60718"), + .remove(target: .user, entryId: "0f1e2d3c4b5a6978"), + ]) + XCTAssertNil(model.errorMessage, "no edit is turned away for arriving during another") + XCTAssertEqual(model.memory?.entryCount, 0) + } + + func testAnEditSavedFromTheClosedSheetReportsAFailureOnThePage() async throws { + let transport = FakeBotMemoryTransport() + let model = await loadedModel(transport, try fixtureMemory()) + transport.queueEdit(.failure(try serverError(status: 422, code: "memory_blocked"))) + transport.queueRead(.success(try fixtureMemory())) + model.saveInBackground(target: .user, entryId: "0f1e2d3c4b5a6978", text: "password: hunter2") + try await waitUntil({ model.errorMessage != nil }, what: "the failure") + XCTAssertEqual(model.errorMessage, AidenBotMemoryCopy.blocked) + XCTAssertEqual(model.memory, try fixtureMemory(), "the Mac's view is back") + } + func testUsageLineReadsLikeTheDesktop() { XCTAssertEqual( AidenBotMemoryCopy.usage(used: 563, limit: 1_375, locale: Locale(identifier: "en_US")), diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index 1243b62d2..c6033a082 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -1,25 +1,150 @@ +import SwiftUI import XCTest @testable import AidenOnTheGo +/// Holds scripted requests until the test lets them go. A latch: a release +/// that comes before its request is kept for it, several requests can wait at +/// once, a cancelled request stops waiting, and none waits longer than +/// `limit`, so no test can hang on it. +private final class TestGate: @unchecked Sendable { + private let lock = NSLock() + private let limit: Duration + private var armed = false + private var permits = 0 + private var order: [UUID] = [] + private var waiters: [UUID: CheckedContinuation] = [:] + /// Waits that ended (cancelled or timed out) before they began. + private var ended: Set = [] + + init(limit: Duration = .seconds(10)) { self.limit = limit } + + var isHolding: Bool { lock.withLock { !waiters.isEmpty } } + var holdingCount: Int { lock.withLock { waiters.count } } + /// Whether requests wait for a `release()`. + var isArmed: Bool { lock.withLock { armed } } + + /// From now on, each request waits for a `release()`. Arming starts with no + /// releases kept, so none carries over from an earlier hold. + func hold() { + lock.withLock { + if !armed { permits = 0 } + armed = true + } + } + + func pass() async { + let id = UUID() + let limit = limit + let timer = Task { [weak self] in + try? await Task.sleep(for: limit) + self?.end(id) + } + await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let goNow = lock.withLock { () -> Bool in + if !armed || ended.remove(id) != nil { return true } + if permits > 0 { + permits -= 1 + return true + } + order.append(id) + waiters[id] = continuation + return false + } + if goNow { continuation.resume() } + } + } onCancel: { + self.end(id) + } + timer.cancel() + } + + /// Lets the oldest waiting request go, or the next one if none waits yet. + /// Unarmed, nothing waits and nothing is kept. + func release() { + let waiting = lock.withLock { () -> CheckedContinuation? in + guard !order.isEmpty else { + if armed { permits += 1 } + return nil + } + return waiters.removeValue(forKey: order.removeFirst()) + } + waiting?.resume() + } + + /// Stops holding and lets every waiting request go. + func releaseAll() { + let waiting = lock.withLock { () -> [CheckedContinuation] in + armed = false + permits = 0 + defer { + order = [] + waiters = [:] + } + return order.compactMap { waiters[$0] } + } + for continuation in waiting { continuation.resume() } + } + + private func end(_ id: UUID) { + let waiting = lock.withLock { () -> CheckedContinuation? in + guard let continuation = waiters.removeValue(forKey: id) else { + ended.insert(id) + return nil + } + order.removeAll { $0 == id } + return continuation + } + waiting?.resume() + } +} + /// A scripted host for `AidenBotSessionModel`. Every request is recorded so a /// test can assert what reached the Mac. private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecked Sendable { private let lock = NSLock() private var _sessions: [Result] = [] - private var _streams: [[AidenBotSessionEvent]] = [] + private var _streams: [(events: [AidenBotSessionEvent], staysOpen: Bool)] = [] private var _sessionRequests = 0 private var _streamRequests = 0 private var _resumeKeys: [UUID] = [] private var _sendKeys: [UUID] = [] private var _sendTexts: [String] = [] + private var _sendModes: [AidenStreamInputMode?] = [] + private var _streamErrors: [Error] = [] private var _sendErrors: [Error] = [] + private var _keyErrors: [UUID: Error] = [:] private var _connectionKeys: [UUID] = [] private var _resumeResults: [Result] = [] private var _connectionError: Error? private var _proposalRequests: [(proposalId: String, decision: AidenBotRoutineProposalDecision, key: UUID)] = [] private var _proposalResults: [Result] = [] - private var resumeGate: CheckedContinuation? - var holdResume = false + let resumeGate = TestGate() + let sendGate = TestGate() + let stopGate = TestGate() + /// Holds each Resume until `releaseResume()`. + var holdResume: Bool { + get { resumeGate.isArmed } + set { if newValue { resumeGate.hold() } else { resumeGate.releaseAll() } } + } + /// Holds each message request until `releaseSend()`. + var holdSend: Bool { + get { sendGate.isArmed } + set { if newValue { sendGate.hold() } else { sendGate.releaseAll() } } + } + /// Holds each Stop until `releaseStop()`. + var holdStop: Bool { + get { stopGate.isArmed } + set { if newValue { stopGate.hold() } else { stopGate.releaseAll() } } + } + private var _stopResults: [Result] = [] + private var _stopKeys: [UUID] = [] + private var _receiptIDs: [String] = [] + private var _dismissResults: [Result] = [] + let dismissGate = TestGate() + let proposalGate = TestGate() + /// Holds `GET /session` while armed. + let sessionGate = TestGate() private func locked(_ body: () -> T) -> T { lock.lock() @@ -28,10 +153,27 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke } func queueSession(_ result: Result) { locked { _sessions.append(result) } } - func queueStream(_ events: [AidenBotSessionEvent]) { locked { _streams.append(events) } } + func queueStream(_ events: [AidenBotSessionEvent], staysOpen: Bool = false) { locked { _streams.append((events, staysOpen)) } } + /// The next stream fails at once with `error`. + func queueStreamFailure(_ error: Error) { + locked { + _streams.append(([], false)) + _streamErrors.append(error) + } + } func queueResume(_ result: Result) { locked { _resumeResults.append(result) } } func failConnections(with error: Error) { locked { _connectionError = error } } func failNextSend(with error: Error) { locked { _sendErrors.append(error) } } + /// Every request under `key` fails with `error` from now on, as the Mac's ledger replays it. + func answerKey(_ key: UUID, with error: Error) { locked { _keyErrors[key] = error } } + func queueStop(_ result: Result) { locked { _stopResults.append(result) } } + var isHoldingSend: Bool { sendGate.isHolding } + var isHoldingResume: Bool { resumeGate.isHolding } + /// The submission ids the next receipts carry, in order; "17" once they run out. + func queueReceiptID(_ id: String) { locked { _receiptIDs.append(id) } } + func queueDismiss(_ result: Result) { locked { _dismissResults.append(result) } } + var isHoldingStop: Bool { stopGate.isHolding } + var stopKeys: [UUID] { locked { _stopKeys } } func queueProposalResult(_ result: Result) { locked { _proposalResults.append(result) } } @@ -44,19 +186,17 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke var resumeKeys: [UUID] { locked { _resumeKeys } } var sendKeys: [UUID] { locked { _sendKeys } } var sendTexts: [String] { locked { _sendTexts } } + var sendModes: [AidenStreamInputMode?] { locked { _sendModes } } var connectionKeys: [UUID] { locked { _connectionKeys } } - func releaseResume() { - let gate = locked { () -> CheckedContinuation? in - defer { resumeGate = nil } - return resumeGate - } - gate?.resume() - } + func releaseResume() { resumeGate.release() } + func releaseSend() { sendGate.release() } + func releaseStop() { stopGate.release() } func botSession(botId: String) async throws -> AidenBotSession { + locked { _sessionRequests += 1 } + await sessionGate.pass() let next = locked { () -> Result? in - _sessionRequests += 1 return _sessions.isEmpty ? nil : _sessions.removeFirst() } guard let next else { throw URLError(.notConnectedToInternet) } @@ -64,14 +204,18 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke } func botSessionEvents(botId: String) -> AsyncThrowingStream { - let events = locked { () -> [AidenBotSessionEvent] in + let (next, failure) = locked { () -> ((events: [AidenBotSessionEvent], staysOpen: Bool), Error?) in _streamRequests += 1 - return _streams.isEmpty ? [] : _streams.removeFirst() + let next: (events: [AidenBotSessionEvent], staysOpen: Bool) = _streams.isEmpty ? ([], false) : _streams.removeFirst() + let failure: Error? = next.events.isEmpty && !next.staysOpen && !_streamErrors.isEmpty ? _streamErrors.removeFirst() : nil + return (next, failure) } return AsyncThrowingStream { continuation in - for event in events { continuation.yield(event) } - if events.isEmpty { - continuation.finish(throwing: URLError(.networkConnectionLost)) + for event in next.events { continuation.yield(event) } + // A stream that stays open ends only when its listener is cancelled. + if next.staysOpen { return } + if next.events.isEmpty { + continuation.finish(throwing: failure ?? URLError(.networkConnectionLost)) } else { continuation.finish() } @@ -86,32 +230,37 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke let error = locked { () -> Error? in _sendKeys.append(idempotencyKey) _sendTexts.append(request.text) + _sendModes.append(request.whenBusy) + if let replayed = _keyErrors[idempotencyKey] { return replayed } return _sendErrors.isEmpty ? nil : _sendErrors.removeFirst() } + await sendGate.pass() if let error { throw error } + let id = locked { _receiptIDs.isEmpty ? "17" : _receiptIDs.removeFirst() } return try AidenRemoteJSONDecoder.decode( AidenBotMessageReceipt.self, - from: Data(#"{"submissionId":"17","deduped":false,"state":"running","interrupted":false}"#.utf8) + from: Data(#"{"submissionId":"\#(id)","deduped":false,"state":"running","interrupted":false}"#.utf8) ) } func resumeBotSession(botId: String, idempotencyKey: UUID) async throws -> AidenBotSessionStateView { locked { _resumeKeys.append(idempotencyKey) } - if holdResume { - await withCheckedContinuation { continuation in - locked { resumeGate = continuation } - } - } + await resumeGate.pass() let next = locked { _resumeResults.isEmpty ? nil : _resumeResults.removeFirst() } return try (next ?? .success(try AidenBotSessionStateView(state: .running))).get() } func dismissBotSession(botId: String, idempotencyKey: UUID) async throws -> AidenBotSessionStateView { - try AidenBotSessionStateView(state: .idle) + await dismissGate.pass() + let next = locked { _dismissResults.isEmpty ? nil : _dismissResults.removeFirst() } + return try (next ?? .success(try AidenBotSessionStateView(state: .idle))).get() } func stopBotSession(botId: String, idempotencyKey: UUID) async throws -> AidenBotSessionStateView { - try AidenBotSessionStateView(state: .idle) + locked { _stopKeys.append(idempotencyKey) } + await stopGate.pass() + let next = locked { _stopResults.isEmpty ? nil : _stopResults.removeFirst() } + return try (next ?? .success(try AidenBotSessionStateView(state: .idle))).get() } func answerBotQuestion( @@ -158,6 +307,7 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke _proposalRequests.append((proposalId, request.decision, idempotencyKey)) return _proposalResults.isEmpty ? nil : _proposalResults.removeFirst() } + await proposalGate.pass() guard let next else { throw URLError(.notConnectedToInternet) } return try next.get() } @@ -409,7 +559,11 @@ final class AidenBotSessionTests: XCTestCase { transport.holdResume = true let first = Task { await model.resume() } - while transport.resumeKeys.isEmpty { try await Task.sleep(for: .milliseconds(5)) } + let deadline = Date().addingTimeInterval(5) + while !transport.isHoldingResume { + guard Date() < deadline else { return XCTFail("Timed out waiting for the Resume request") } + try await Task.sleep(for: .milliseconds(5)) + } XCTAssertTrue(model.inFlight.contains(.resume), "The card disables its buttons while resuming.") await model.resume() XCTAssertEqual(transport.resumeKeys.count, 1, "A second tap while in flight sends nothing.") @@ -505,25 +659,31 @@ final class AidenBotSessionTests: XCTestCase { XCTAssertEqual(model.state, .running) } - func testAmbiguousRetryFailureReusesItsKeyAndAnotherFailedTurnGetsANewOne() async throws { + func testALostRetryKeepsItsKeyUntilTheMacIsBackAndAnotherFailedTurnGetsANewOne() async throws { let transport = FakeBotSessionTransport() - let model = await loadedModel(transport, try session(seq: 1, entries: [ - failedTurn("t1:failed", retryText: "First"), - ])) + transport.queueSession(.success(try session(seq: 1, entries: [failedTurn("t1:failed", retryText: "First")]))) + let model = AidenBotSessionModel(botID: botID, transport: transport, retryDelays: [.seconds(60)]) + await model.load() transport.failNextSend(with: URLError(.networkConnectionLost)) transport.failNextSend(with: URLError(.networkConnectionLost)) - let lost = await model.retry() - XCTAssertFalse(lost) - XCTAssertNotNil(model.errorMessage) - _ = await model.retry() - XCTAssertEqual(transport.sendKeys.count, 2) - XCTAssertEqual(transport.sendKeys[0], transport.sendKeys[1], "Tapping Retry again replays the same request.") + let taken = await model.retry() + XCTAssertTrue(taken, "the Retry shows at once and waits for the Mac") + XCTAssertEqual(model.outgoing.map(\.text), ["First"]) + XCTAssertNil(model.retryableFailedTurn, "one Retry at a time") + let again = await model.retry() + XCTAssertFalse(again) + for seq in 2...3 { + transport.queueSession(.success(try session(seq: seq, entries: [failedTurn("t1:failed", retryText: "First")]))) + await model.load() + } + XCTAssertEqual(transport.sendKeys.count, 3) + XCTAssertEqual(Set(transport.sendKeys).count, 1, "every attempt of the Retry replays one request") - await model.apply(event(seq: 2, .entry(failedTurn("t2:failed", retryText: "Second")))) + await model.apply(event(seq: 4, .entry(failedTurn("t2:failed", retryText: "Second")))) _ = await model.retry() XCTAssertEqual(transport.sendTexts.last, "Second") - XCTAssertNotEqual(transport.sendKeys[2], transport.sendKeys[1], "A different failed turn is a new request.") + XCTAssertNotEqual(transport.sendKeys[3], transport.sendKeys[2], "a different failed turn is a new request") } func testOnlyTheNewestFailedTurnWithTextCanBeRetried() async throws { @@ -1307,6 +1467,7 @@ private final class QuestionTransport: AidenBotSessionTransport, @unchecked Send private(set) var approvals: [(waitId: String, decision: AidenApprovalDecision, scope: AidenApprovalScope?, key: UUID)] = [] /// Thrown by the next approval response instead of a resolution. var nextApprovalError: Error? + let answerGate = TestGate() init(snapshot: AidenBotSession) { self.snapshot = snapshot @@ -1345,6 +1506,7 @@ private final class QuestionTransport: AidenBotSessionTransport, @unchecked Send idempotencyKey: UUID ) async throws -> AidenBotQuestionAnswerReceipt { answers.append((waitId, idempotencyKey, request)) + await answerGate.pass() if let error = nextAnswerError { nextAnswerError = nil throw error @@ -1362,6 +1524,7 @@ private final class QuestionTransport: AidenBotSessionTransport, @unchecked Send idempotencyKey: UUID ) async throws -> AidenApprovalResponse { approvals.append((id, decision, scope, idempotencyKey)) + await answerGate.pass() if let error = nextApprovalError { nextApprovalError = nil throw error @@ -1386,3 +1549,2668 @@ private final class QuestionTransport: AidenBotSessionTransport, @unchecked Send throw URLError(.unsupportedURL) } } + +/// Contract revision 30: the activity wire, read from the fixture every client shares. +final class AidenBotActivityContractTests: XCTestCase { + private func shared() throws -> [String: Any] { + let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "contract", withExtension: "json")) + return try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + } + + private func section(_ name: String) throws -> [String: Any] { + try XCTUnwrap(try shared()[name] as? [String: Any]) + } + + private func decode(_ type: T.Type, _ object: Any) throws -> T { + try AidenRemoteJSONDecoder.decode(type, from: JSONSerialization.data(withJSONObject: object)) + } + + private func encoded(_ value: T) throws -> NSDictionary { + try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(value)) as? NSDictionary) + } + + /// Expands a fixture `{unit, count}` descriptor: `count` repetitions of `unit`, + /// counted in code points. + private func expand(_ descriptor: Any?) throws -> String { + let value = try XCTUnwrap(descriptor as? [String: Any]) + let unit = try XCTUnwrap(value["unit"] as? String) + let count = try XCTUnwrap(value["count"] as? Int) + return String(repeating: unit, count: count) + } + + func testActivitySessionFramesAndDetailDecodeAsTheMacSendsThem() throws { + let activity = try section("botActivity") + let started = 1_760_450_000_000 + let session = try decode(AidenBotSession.self, try XCTUnwrap(activity["session"])) + XCTAssertEqual(session.activity, AidenBotSessionActivity(phase: "app", app: "Notion", startedAt: started)) + XCTAssertEqual(session.queued.map(\.submissionId), ["18", "19"]) + XCTAssertEqual(session.queued.map(\.mode), [.queue, .steer]) + + let events = try XCTUnwrap(activity["events"] as? [Any]).map { try decode(AidenBotSessionEvent.self, $0) } + XCTAssertEqual(events.map(\.kind), [ + .activity(AidenBotSessionActivity(phase: "searching", startedAt: started)), + .queued([AidenBotQueuedMessage(submissionId: "18", mode: .queue, text: "Also add a shopping list.")]), + .activity(AidenBotSessionActivity(phase: "teleporting", startedAt: started)), + .activity(nil), + ]) + // A newer Mac's phase decodes and reads as Working. + XCTAssertEqual( + AidenBotActivityPhase.display(phase: "teleporting", wording: .follows, app: nil), + AidenBotActivityPhase.display(phase: "working", wording: .follows, app: nil) + ) + + let detail = try decode(AidenBotDetail.self, try XCTUnwrap(activity["detail"])) + XCTAssertEqual(detail.chatActivity, AidenBotChatActivity(level: .summary, wording: .thinking)) + let older = try decode(AidenBotDetail.self, try XCTUnwrap(try shared()["botDetail"])) + XCTAssertNil(older.chatActivity, "a revision-29 detail has no choice to show") + } + + func testEveryPrivacySampleIsRefused() throws { + let privacy = try section("botActivityPrivacy") + for sample in try XCTUnwrap(privacy["rejectedActivities"] as? [Any]) { + XCTAssertThrowsError(try decode(AidenBotSessionActivity.self, sample), "\(sample)") + } + for sample in try XCTUnwrap(privacy["rejectedQueued"] as? [Any]) { + XCTAssertThrowsError(try decode(AidenBotQueuedMessage.self, sample), "\(sample)") + } + for sample in try XCTUnwrap(privacy["rejectedChatActivity"] as? [Any]) { + XCTAssertThrowsError(try decode(AidenBotChatActivity.self, sample), "\(sample)") + } + for list in try XCTUnwrap(privacy["rejectedQueuedLists"] as? [Any]) { + // A list is refused wherever it arrives: a snapshot and a queued frame. + var session = try XCTUnwrap(try section("botActivity")["session"] as? [String: Any]) + session["queued"] = list + XCTAssertThrowsError(try decode(AidenBotSession.self, session), "snapshot: \(list)") + let frame: [String: Any] = [ + "protocolVersion": 1, "botId": "bot_fixture_01", "epoch": "epoch_fixture_02", "seq": 9, + "type": "queued", "payload": ["queued": list], + ] + XCTAssertThrowsError(try decode(AidenBotSessionEvent.self, frame), "frame: \(list)") + } + } + + /// An optional key sent as the literal null is refused, never read as absent (TS and Android parity). + func testEveryNullLiteralSampleIsRefused() throws { + let nulls = try XCTUnwrap(try section("botActivityPrivacy")["rejectedNullLiterals"] as? [String: Any]) + let activities = try XCTUnwrap(nulls["activities"] as? [Any]) + let sessions = try XCTUnwrap(nulls["sessions"] as? [Any]) + let events = try XCTUnwrap(nulls["events"] as? [Any]) + let entries = try XCTUnwrap(nulls["entries"] as? [Any]) + XCTAssertFalse(activities.isEmpty || sessions.isEmpty || events.isEmpty || entries.isEmpty) + for sample in activities { + XCTAssertThrowsError(try decode(AidenBotSessionActivity.self, sample), "\(sample)") + } + for sample in sessions { + XCTAssertThrowsError(try decode(AidenBotSession.self, sample), "\(sample)") + } + for sample in events { + XCTAssertThrowsError(try decode(AidenBotSessionEvent.self, sample), "\(sample)") + } + for sample in entries { + XCTAssertThrowsError(try decode(AidenBotSessionEntry.self, sample), "\(sample)") + } + // Each refusal is the null itself: the same sample without it decodes. + var activity = try XCTUnwrap(activities.first as? [String: Any]) + activity["app"] = "Notion" + XCTAssertEqual(try decode(AidenBotSessionActivity.self, activity).app, "Notion") + var session = try XCTUnwrap(sessions.first as? [String: Any]) + session["queued"] = [Any]() + XCTAssertEqual(try decode(AidenBotSession.self, session).queued, []) + var entry = try XCTUnwrap(entries.first as? [String: Any]) + entry.removeValue(forKey: "label") + guard case let .message(message) = try decode(AidenBotSessionEntry.self, entry) else { return XCTFail("a message") } + XCTAssertEqual(message.submissionId, "17") + } + + func testThePersonsMessageCarriesItsSubmissionIdAndEveryMisuseIsRefused() throws { + let activity = try section("botActivity") + let session = try decode(AidenBotSession.self, try XCTUnwrap(activity["session"])) + guard case let .message(sent) = try XCTUnwrap(session.entries.first) else { return XCTFail("a message") } + XCTAssertEqual(sent.submissionId, "17") + + // The queued message 18 was placed in the turn: its bubble now names the same submission. + let frame = try decode(AidenBotSessionEvent.self, try XCTUnwrap(activity["entryEvent"])) + guard case let .entry(.message(placed)) = frame.kind else { return XCTFail("an entry frame") } + XCTAssertEqual(placed.submissionId, session.queued.first?.submissionId) + XCTAssertEqual(placed.submissionId, "18") + + // A device without bot:activity is sent the same session and frame without the key. + let nonHolder = try XCTUnwrap(try section("botActivityPrivacy")["nonHolder"] as? [String: Any]) + let legacy = try decode(AidenBotSession.self, try XCTUnwrap(nonHolder["session"])) + guard case let .message(untagged) = try XCTUnwrap(legacy.entries.first) else { return XCTFail("a message") } + XCTAssertNil(untagged.submissionId) + XCTAssertEqual(untagged.text, sent.text) + let legacyFrame = try decode(AidenBotSessionEvent.self, try XCTUnwrap(nonHolder["entryEvent"])) + guard case let .entry(.message(untaggedPlaced)) = legacyFrame.kind else { return XCTFail("an entry frame") } + XCTAssertNil(untaggedPlaced.submissionId) + XCTAssertEqual(legacyFrame.seq, frame.seq) + + let rejected = try XCTUnwrap(try section("botActivityPrivacy")["rejectedSubmissionIdEntries"] as? [Any]) + XCTAssertFalse(rejected.isEmpty) + for sample in rejected { + XCTAssertThrowsError(try decode(AidenBotSessionEntry.self, sample), "\(sample)") + } + } + + func testTextBoundsAreCountedInCodePoints() throws { + let bounds = try XCTUnwrap(try section("botActivityPrivacy")["textBounds"] as? [String: Any]) + let queuedText = try XCTUnwrap(bounds["queuedText"] as? [String: Any]) + func queued(_ text: String) -> [String: Any] { + ["submissionId": "18", "mode": "queue", "text": text] + } + for sample in try XCTUnwrap(queuedText["rejected"] as? [Any]) { + XCTAssertThrowsError(try decode(AidenBotQueuedMessage.self, queued(try expand(sample))), "\(sample)") + } + for sample in try XCTUnwrap(queuedText["accepted"] as? [Any]) { + let text = try expand(sample) + XCTAssertEqual(try decode(AidenBotQueuedMessage.self, queued(text)).text, text, "\(sample)") + } + let app = try XCTUnwrap(bounds["app"] as? [String: Any]) + func activity(_ app: String) -> [String: Any] { + ["phase": "app", "app": app, "startedAt": 1_760_450_000_000] + } + for sample in try XCTUnwrap(app["rejected"] as? [Any]) { + XCTAssertThrowsError(try decode(AidenBotSessionActivity.self, activity(try expand(sample))), "\(sample)") + } + for sample in try XCTUnwrap(app["accepted"] as? [Any]) { + let label = try expand(sample) + XCTAssertEqual(try decode(AidenBotSessionActivity.self, activity(label)).app, label, "\(sample)") + } + } + + func testPhaseIdsOutsideTheWireShapeAreRefusedAndOthersDecode() throws { + for id in ["teleporting", "a", "a_b", String(repeating: "a", count: 32)] { + XCTAssertEqual( + try decode(AidenBotSessionActivity.self, ["phase": id, "startedAt": 0]).phase, id) + } + for id in ["", "Reading", "1abc", "_abc", "a-b", "a b", String(repeating: "a", count: 33), "reading\u{FF21}"] { + XCTAssertThrowsError(try decode(AidenBotSessionActivity.self, ["phase": id, "startedAt": 0]), id) + } + // startedAt spans every integer JavaScript represents exactly, 0 through 2^53 − 1. + let latest = 9_007_199_254_740_991 + XCTAssertEqual(try decode(AidenBotSessionActivity.self, ["phase": "reading", "startedAt": latest]).startedAt, latest) + XCTAssertThrowsError(try decode(AidenBotSessionActivity.self, ["phase": "reading", "startedAt": latest + 1])) + XCTAssertThrowsError(try decode(AidenBotSessionActivity.self, ["phase": "reading", "startedAt": -1])) + } + + func testRequestsEncodeExactlyTheFixtureBodies() throws { + let activity = try section("botActivity") + let send = try XCTUnwrap((activity["send"] as? [String: Any])?["request"] as? NSDictionary) + XCTAssertEqual(try encoded(try AidenBotMessageRequest(text: "Keep it vegetarian.", whenBusy: .steer)), send) + XCTAssertEqual(try encoded(try AidenBotMessageRequest(text: "Hi")), ["text": "Hi"] as NSDictionary, "no whenBusy unless one was chosen") + let patch = try XCTUnwrap((activity["identityPatch"] as? [String: Any])?["request"] as? NSDictionary) + XCTAssertEqual(try encoded(try AidenBotIdentityPatch(chatActivity: AidenBotChatActivity(level: .detailed, wording: .follows))), patch) + } + + func testThePhaseTableIsTheOneTheMacDraws() throws { + let activity = try section("botActivity") + let phases = try XCTUnwrap(activity["phases"] as? [[String: String]]) + let keeps = Set(try XCTUnwrap(activity["thinkingKeeps"] as? [String])) + XCTAssertEqual(Set(AidenBotActivityPhase.table.keys), Set(phases.compactMap { $0["id"] })) + for row in phases { + let id = try XCTUnwrap(row["id"]) + let shown = AidenBotActivityPhase.display(phase: id, wording: .follows, app: nil) + XCTAssertEqual(shown.phase, id) + XCTAssertEqual(shown.word, row["word"], id) + XCTAssertEqual(shown.mark.rawValue, row["mark"], id) + XCTAssertEqual( + AidenBotActivityPhase.display(phase: id, wording: .thinking, app: nil).phase, + keeps.contains(id) ? id : "thinking", + id + ) + } + let app = try XCTUnwrap(activity["app"] as? [String: String]) + XCTAssertEqual(AidenBotActivityPhase.display(phase: "app", wording: .follows, app: app["app"]).word, app["word"]) + } + + func testElapsedTimeMatchesTheMacIncludingAPhoneWhoseClockIsBehind() throws { + let rows = try XCTUnwrap(try section("botActivity")["elapsed"] as? [[String: Any]]) + for row in rows { + let startedAt = try XCTUnwrap(row["startedAt"] as? NSNumber).intValue + let now = try XCTUnwrap(row["now"] as? NSNumber).intValue + XCTAssertEqual(AidenBotElapsed.label(startedAt: startedAt, nowMs: now), row["label"] as? String, "\(row)") + } + } +} + +/// Contract revision 30 in the session model: the status line, Reconnecting, +/// Queued messages and Steer/Queue. +@MainActor +final class AidenBotSessionActivityTests: XCTestCase { + private let botID = "bot_fixture_01" + private let started = 1_760_450_000_000 + + private func running( + _ activity: AidenBotSessionActivity?, + epoch: String = "epoch_a", + seq: Int = 4, + partial: String? = nil, + queued: [AidenBotQueuedMessage] = [] + ) throws -> AidenBotSession { + AidenBotSession( + botId: botID, + epoch: epoch, + seq: seq, + stateView: try AidenBotSessionStateView(state: .running), + partial: partial, + entries: [], + hasOlder: false, + activity: activity, + queued: queued + ) + } + + private func idle() throws -> AidenBotSession { + AidenBotSession( + botId: botID, + epoch: "epoch_a", + seq: 4, + stateView: try AidenBotSessionStateView(state: .idle), + partial: nil, + entries: [], + hasOlder: false + ) + } + + private func event(epoch: String = "epoch_a", seq: Int, _ kind: AidenBotSessionEvent.Kind) -> AidenBotSessionEvent { + AidenBotSessionEvent(botId: botID, epoch: epoch, seq: seq, kind: kind) + } + + private func loaded( + _ transport: FakeBotSessionTransport, + _ session: AidenBotSession, + supported: Bool = true + ) async -> AidenBotSessionModel { + transport.queueSession(.success(session)) + let model = AidenBotSessionModel(botID: botID, transport: transport) + model.activitySupported = supported + await model.load() + return model + } + + private func waitUntil(_ condition: () -> Bool, what: String) async throws { + let deadline = Date().addingTimeInterval(5) + while !condition() { + guard Date() < deadline else { return XCTFail("Timed out waiting for \(what)") } + try await Task.sleep(for: .milliseconds(20)) + } + } + + // Test 1 + func testTheLineShowsUntilTheReplyStreamsAndFollowsTheChosenWording() async throws { + let model = await loaded(FakeBotSessionTransport(), try running(AidenBotSessionActivity(phase: "reading", startedAt: started))) + XCTAssertEqual(model.statusLine(nowMs: started + 1_000)?.display.word, "Reading") + XCTAssertEqual(model.statusLine(nowMs: started + 1_000)?.display.mark, .scanGrid) + model.wording = .thinking + XCTAssertEqual(model.statusLine(nowMs: started + 1_000)?.display.word, "Thinking") + _ = await model.apply(event(seq: 5, .activity(AidenBotSessionActivity(phase: "waiting", startedAt: started)))) + XCTAssertEqual(model.statusLine(nowMs: started + 1_000)?.display.word, "Waiting for you", "a card that needs the person keeps its word") + _ = await model.apply(event(seq: 6, .partial("Here is"))) + XCTAssertEqual( + model.statusLine(nowMs: started + 1_000)?.display.word, "Waiting for you", + "text from before a running step does not hide the step" + ) + _ = await model.apply(event(seq: 7, .activity(AidenBotSessionActivity(phase: "writing", startedAt: started)))) + XCTAssertNil(model.statusLine(nowMs: started + 1_000), "the reply's words are the signal") + _ = await model.apply(event(seq: 8, .state(try AidenBotSessionStateView(state: .idle)))) + _ = await model.apply(event(seq: 9, .partial(""))) + XCTAssertNil(model.statusLine(nowMs: started + 1_000), "nothing shows once the turn ended") + } + + // R7: the line hides only while reply text streams with no active tool. + func testStreamingTextHidesTheLineOnlyWhileNoToolRuns() async throws { + let model = await loaded(FakeBotSessionTransport(), try running(nil, partial: "Let me check")) + XCTAssertNil(model.statusLine(nowMs: started), "words streaming before the first frame are the signal") + _ = await model.apply(event(seq: 5, .activity(AidenBotSessionActivity(phase: "searching", startedAt: started)))) + XCTAssertEqual(model.statusLine(nowMs: started)?.display.word, "Searching the web", "a tool that starts mid-narration shows") + _ = await model.apply(event(seq: 6, .activity(AidenBotSessionActivity(phase: "thinking", startedAt: started)))) + XCTAssertNil(model.statusLine(nowMs: started)) + _ = await model.apply(event(seq: 7, .activity(AidenBotSessionActivity(phase: "browsing", startedAt: started)))) + XCTAssertEqual(model.statusLine(nowMs: started)?.display.word, "Working", "a newer Mac's phase is not reply text") + + let blank = await loaded(FakeBotSessionTransport(), try running(nil, partial: " \n")) + XCTAssertEqual(blank.statusLine(nowMs: started)?.display.word, "Getting ready", "whitespace is not a reply") + } + + // Test 2 (Review Focus 3) + func testAnUnknownPhaseReadsAsWorkingAndElapsedTimeAppearsAfterTwentySeconds() async throws { + let model = await loaded(FakeBotSessionTransport(), try running(AidenBotSessionActivity(phase: "browsing", startedAt: started))) + let early = try XCTUnwrap(model.statusLine(nowMs: started + 19_000)) + XCTAssertEqual(early.display.word, "Working") + XCTAssertEqual(early.display.mark, .quadShuffle) + XCTAssertNil(early.elapsed) + XCTAssertEqual(early.accessibilityLabel, "Working") + let later = try XCTUnwrap(model.statusLine(nowMs: started + 72_000)) + XCTAssertEqual(later.elapsed, "1m 12s") + XCTAssertEqual(later.accessibilityLabel, "Working, 1m 12s") + } + + // Test 3 + func testBeforeTheFirstActivityFrameItSaysGettingReadyAndOlderMacsShowNothing() async throws { + let fresh = await loaded(FakeBotSessionTransport(), try running(nil)) + XCTAssertEqual(fresh.statusLine(nowMs: started)?.display.word, "Getting ready") + let older = await loaded( + FakeBotSessionTransport(), + try running(AidenBotSessionActivity(phase: "reading", startedAt: started)), + supported: false + ) + XCTAssertNil(older.statusLine(nowMs: started)) + XCTAssertFalse(older.offersBusyModes) + } + + // Test 4 (Review Focus 2) + func testADroppedStreamReadsReconnectingUntilTheNextConnection() async throws { + let transport = FakeBotSessionTransport() + transport.queueStream([]) // the first connection drops at once + transport.queueStream( + [event(epoch: "epoch_b", seq: 1, .snapshot(try running(AidenBotSessionActivity(phase: "searching", startedAt: started), epoch: "epoch_b", seq: 1)))], + staysOpen: true + ) + let model = await loaded( + transport, + try running(AidenBotSessionActivity(phase: "writing", startedAt: started), partial: "Here is") + ) + XCTAssertNil(model.statusLine(nowMs: started + 1_000), "the reply streams before the drop") + let listener = Task { await model.listen() } + + try await waitUntil({ model.isStreamDown }, what: "the drop") + XCTAssertEqual( + model.statusLine(nowMs: started + 1_000)?.display.word, "Reconnecting to your Mac", + "a dropped stream says so even over streamed text" + ) + model.wording = .thinking + XCTAssertEqual(model.statusLine(nowMs: started + 1_000)?.display.word, "Reconnecting to your Mac") + model.wording = .follows + try await waitUntil({ !model.isStreamDown && model.epoch == "epoch_b" }, what: "the next connection") + XCTAssertEqual(model.statusLine(nowMs: started + 1_000)?.display.word, "Searching the web") + + listener.cancel() + await listener.value + } + + func testAReloadedSnapshotRestoresTheLineAndTheQueuedMessages() async throws { + let waiting = [ + AidenBotQueuedMessage(submissionId: "18", mode: .queue, text: "Also add a shopping list."), + AidenBotQueuedMessage(submissionId: "19", mode: .steer, text: "Keep it vegetarian."), + ] + let model = await loaded( + FakeBotSessionTransport(), + try running(AidenBotSessionActivity(phase: "app", app: "Notion", startedAt: started), queued: waiting) + ) + XCTAssertEqual(model.activity, AidenBotSessionActivity(phase: "app", app: "Notion", startedAt: started)) + XCTAssertEqual(model.queued, waiting) + XCTAssertEqual(model.statusLine(nowMs: started)?.display.word, "Using Notion") + _ = await model.apply(event(seq: 5, .activity(nil))) + XCTAssertEqual(model.statusLine(nowMs: started)?.display.word, "Getting ready", "a cleared activity never leaves the old phase showing") + } + + // Test 5 + func testSteerAndQueueGoOutOnlyWhileTheBotWorksOnAMacThatOffersThem() async throws { + let transport = FakeBotSessionTransport() + let supported = await loaded(transport, try running(nil)) + XCTAssertTrue(supported.offersBusyModes) + let steered = await supported.send("Keep it vegetarian.", whenBusy: .steer) + XCTAssertTrue(steered) + let older = await loaded(transport, try running(nil), supported: false) + let queuedOnOlder = await older.send("Also a list.", whenBusy: .queue) + XCTAssertTrue(queuedOnOlder) + let resting = await loaded(transport, try idle()) + XCTAssertFalse(resting.offersBusyModes) + let sentIdle = await resting.send("Hello.", whenBusy: .steer) + XCTAssertTrue(sentIdle) + XCTAssertEqual(transport.sendModes, [.steer, nil, nil], "an older Mac would refuse whenBusy, and an idle Bot has nothing to steer") + } + + // Test 6 + func testQueuedMessagesFollowTheirFramesAndAFreshSnapshotReplacesThem() async throws { + let waiting = [AidenBotQueuedMessage(submissionId: "18", mode: .queue, text: "Also add a shopping list.")] + let model = await loaded(FakeBotSessionTransport(), try running(nil)) + _ = await model.apply(event(seq: 5, .queued(waiting))) + XCTAssertEqual(model.queued, waiting) + _ = await model.apply(event(seq: 6, .snapshot(try running(nil, seq: 6)))) + XCTAssertEqual(model.queued, []) + } + + /// FR-m1: an idle chat runs no once-a-second timer for a line that can't show. + func testTheLineClockTicksOnlyWhileTheLineCanShow() async throws { + let idleChat = await loaded(FakeBotSessionTransport(), try idle()) + XCTAssertFalse(idleChat.statusLineTicks, "an idle chat runs no timer") + let working = await loaded(FakeBotSessionTransport(), try running(AidenBotSessionActivity(phase: "reading", startedAt: started))) + XCTAssertTrue(working.statusLineTicks) + let olderMac = await loaded( + FakeBotSessionTransport(), try running(AidenBotSessionActivity(phase: "reading", startedAt: started)), supported: false + ) + XCTAssertFalse(olderMac.statusLineTicks, "no line on a Mac without bot-activity-v1") + + // A message sent from idle ticks at once, before the Mac's state catches up. + let transport = FakeBotSessionTransport() + let sending = await loaded(transport, try idle()) + transport.holdSend = true + let sent = Task { await sending.send("Hi") } + try await waitUntil({ transport.isHoldingSend }, what: "the request") + XCTAssertTrue(sending.statusLineTicks) + transport.releaseSend() + _ = await sent.value + } + + func testAWordIsAnnouncedAtOnceAfterTheLineHid() { + var announcer = AidenBotStatusAnnouncer() + XCTAssertEqual(announcer.offer("Reading", at: 0).announce, "Reading") + XCTAssertNil(announcer.offer("Reading", at: 0.5).announce) + announcer.reset() + XCTAssertEqual(announcer.offer("Reading", at: 0.6).announce, "Reading", "a new run's first word is news, with no wait") + } + + // Test 7 + func testScreenReadersHearAStatusChangeAtMostEveryTwoSeconds() { + var announcer = AidenBotStatusAnnouncer() + XCTAssertEqual(announcer.offer("Reading", at: 0).announce, "Reading") + XCTAssertNil(announcer.offer("Reading", at: 0.5).announce, "the same word is not repeated") + let early = announcer.offer("Searching the web", at: 1) + XCTAssertNil(early.announce) + XCTAssertEqual(early.retryAfter ?? 0, 1, accuracy: 0.001) + XCTAssertEqual(announcer.offer("Searching the web", at: 2).announce, "Searching the web") + XCTAssertNil(announcer.offer("Writing", at: 3.9).announce) + XCTAssertEqual(announcer.offer("Writing", at: 4).announce, "Writing") + } +} + +/// R14, R16–R18: every phone Bot action applies at once and reconciles with the Mac later. +@MainActor +final class AidenBotSessionOptimisticTests: XCTestCase { + private let botID = "bot_fixture_01" + private let started = 1_760_450_000_000 + private var roots: [URL] = [] + + override func tearDown() { + for root in roots { try? FileManager.default.removeItem(at: root) } + roots = [] + super.tearDown() + } + + private func session( + _ state: AidenBotSessionStateKind, + seq: Int = 4, + blocked: AidenBotSessionBlockedReason? = nil, + entries: [AidenBotSessionEntry] = [], + activity: AidenBotSessionActivity? = nil, + queued: [AidenBotQueuedMessage] = [] + ) throws -> AidenBotSession { + AidenBotSession( + botId: botID, + epoch: "epoch_a", + seq: seq, + stateView: try AidenBotSessionStateView(state: state, blocked: blocked), + partial: nil, + entries: entries, + hasOlder: false, + activity: activity, + queued: queued + ) + } + + private func user(_ id: String, _ text: String, submissionId: String? = nil) -> AidenBotSessionEntry { + .message(AidenBotSessionMessage( + id: id, role: .user, text: text, createdAt: nil, label: nil, interrupted: false, submissionId: submissionId + )) + } + + private func assistant(_ id: String, _ text: String) -> AidenBotSessionEntry { + .message(AidenBotSessionMessage(id: id, role: .assistant, text: text, createdAt: nil, label: nil, interrupted: false)) + } + + private func event(seq: Int, _ kind: AidenBotSessionEvent.Kind) -> AidenBotSessionEvent { + AidenBotSessionEvent(botId: botID, epoch: "epoch_a", seq: seq, kind: kind) + } + + /// Retries while connected wait a minute unless a test asks otherwise. + private func model( + _ transport: FakeBotSessionTransport, + supported: Bool = true, + key: AidenBotSessionKey? = nil, + cache: AidenBotSessionCache? = nil, + store: AidenBotOutboxStore? = nil, + retryDelays: [Duration] = [.seconds(60)], + requestLimit: Duration = .seconds(12), + now: @escaping () -> Date = { Date() } + ) -> AidenBotSessionModel { + let model = AidenBotSessionModel( + botID: botID, + transport: transport, + cacheKey: key, + cache: cache ?? AidenBotSessionCache(), + outboxStore: store, + retryDelays: retryDelays, + requestLimit: requestLimit, + now: now + ) + model.activitySupported = supported + return model + } + + private func loaded( + _ transport: FakeBotSessionTransport, + _ snapshot: AidenBotSession, + supported: Bool = true, + retryDelays: [Duration] = [.seconds(60)] + ) async -> AidenBotSessionModel { + transport.queueSession(.success(snapshot)) + let model = model(transport, supported: supported, retryDelays: retryDelays) + await model.load() + return model + } + + private func temporaryStore(now: @escaping @Sendable () -> Date = { Date() }) -> (AidenBotOutboxStore, URL) { + let root = FileManager.default.temporaryDirectory.appending(path: "bot-outbox-\(UUID().uuidString)", directoryHint: .isDirectory) + roots.append(root) + return (AidenBotOutboxStore(root: root, now: now), root) + } + + private func serverError(status: Int, code: String) throws -> AidenRemoteClientError { + let body = try AidenRemoteJSONDecoder.decode( + AidenRemoteErrorEnvelope.Body.self, + from: Data(#"{"code":"\#(code)","message":"No.","requestId":"req_test_1","retryable":false}"#.utf8) + ) + return .server(statusCode: status, body: body) + } + + private func waitUntil(_ condition: () -> Bool, what: String) async throws { + let deadline = Date().addingTimeInterval(5) + while !condition() { + guard Date() < deadline else { return XCTFail("Timed out waiting for \(what)") } + try await Task.sleep(for: .milliseconds(10)) + } + } + + private func stored(_ store: AidenBotOutboxStore, _ key: AidenBotSessionKey, count: Int) async throws { + let deadline = Date().addingTimeInterval(5) + while await store.load(key).count != count { + guard Date() < deadline else { return XCTFail("Timed out waiting for \(count) stored messages") } + try await Task.sleep(for: .milliseconds(10)) + } + } + + /// Every bubble of one text the chat would draw: transcript, the Mac's Queued, and outgoing. + private func bubbles(_ text: String, in model: AidenBotSessionModel) -> Int { + let transcript = model.entries.filter { entry in + if case let .message(message) = entry { return message.role == .user && message.text == text } + return false + }.count + return transcript + model.queued.filter { $0.text == text }.count + model.outgoing.filter { $0.text == text }.count + } + + // MARK: Sending + + func testAMessageShowsAtOnceAndTheLineSaysGettingReadyBeforeTheMacAnswers() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.idle)) + transport.holdSend = true + let sending = Task { await model.send("Plan my week.") } + try await waitUntil({ transport.isHoldingSend }, what: "the request") + + let shown = try XCTUnwrap(model.outgoing.first) + XCTAssertEqual(shown.text, "Plan my week.") + XCTAssertEqual(model.look(of: shown), .sending) + XCTAssertEqual(model.statusLine(nowMs: started)?.display.word, "Getting ready") + + transport.releaseSend() + let taken = await sending.value + XCTAssertTrue(taken) + XCTAssertEqual(model.look(of: try XCTUnwrap(model.outgoing.first)), .sent, "it is the turn the Bot now works on, not a queued message") + XCTAssertEqual(model.state, .running) + } + + func testOnAnOlderMacAMessageNeverReadsQueuedAndNoLineShows() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.running), supported: false) + transport.holdSend = true + let sending = Task { await model.send("Also a list.") } + try await waitUntil({ transport.isHoldingSend }, what: "the request") + XCTAssertEqual(model.look(of: try XCTUnwrap(model.outgoing.first)), .sending) + XCTAssertNil(model.statusLine(nowMs: started)) + transport.releaseSend() + _ = await sending.value + XCTAssertEqual(model.look(of: try XCTUnwrap(model.outgoing.first)), .sent) + XCTAssertNil(model.statusLine(nowMs: started)) + XCTAssertEqual(transport.sendModes, [nil]) + } + + func testOneSendAndItsEntryFromTheMacLeaveExactlyOneBubble() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.idle, entries: [user("entry_1", "Hi")])) + _ = await model.send("Hi") + XCTAssertEqual(bubbles("Hi", in: model), 2, "the earlier Hi and this one") + + _ = await model.apply(event(seq: 5, .entry(user("entry_2", "Hi")))) + XCTAssertEqual(model.outgoing, [], "the Mac's entry stands in for it") + XCTAssertEqual(bubbles("Hi", in: model), 2) + _ = await model.apply(event(seq: 6, .entry(user("entry_2", "Hi")))) + XCTAssertEqual(bubbles("Hi", in: model), 2, "a repeated entry is still one bubble") + + // A fresh snapshot reconciles the same way. + _ = await model.send("Thanks") + _ = await model.apply(event(seq: 7, .snapshot(try session(.running, seq: 7, entries: [ + user("entry_1", "Hi"), user("entry_2", "Hi"), user("entry_3", "Thanks"), + ])))) + XCTAssertEqual(bubbles("Thanks", in: model), 1) + } + + func testATakenMessageSettlesOnlyOnTheEntryWithItsSubmissionId() async throws { + let transport = FakeBotSessionTransport() + transport.queueReceiptID("17") + transport.queueReceiptID("18") + let model = await loaded(transport, try session(.idle)) + _ = await model.send("Hi") + _ = await model.send("Hi") + let first = try XCTUnwrap(model.outgoing.first) + XCTAssertEqual(model.outgoing.map(\.status), [.sent(submissionId: "17"), .sent(submissionId: "18")]) + + _ = await model.apply(event(seq: 5, .entry(user("entry_9", "Hi", submissionId: "99")))) + XCTAssertEqual(model.outgoing.count, 2, "another submission's Hi settles neither") + _ = await model.apply(event(seq: 6, .entry(user("entry_10", "Hi", submissionId: "18")))) + XCTAssertEqual(model.outgoing.map(\.key), [first.key], "the second Hi settled, though the first came first") + _ = await model.apply(event(seq: 7, .entry(user("entry_11", "Hi", submissionId: "17")))) + XCTAssertEqual(model.outgoing, []) + XCTAssertEqual(bubbles("Hi", in: model), 3) + } + + func testTwoIdenticalTextsKeepTwoBubblesAndTheDeferredOneStillGoesOut() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.idle), supported: false) + _ = await model.send("Hi") + transport.failNextSend(with: URLError(.networkConnectionLost)) + _ = await model.send("Hi") + let second = try XCTUnwrap(model.outgoing.last) + XCTAssertEqual(bubbles("Hi", in: model), 2) + + // An older Mac's entries carry no submission id: text settles the first Hi only. + _ = await model.apply(event(seq: 5, .entry(user("entry_1", "Hi")))) + XCTAssertEqual(bubbles("Hi", in: model), 2) + _ = await model.apply(event(seq: 6, .entry(assistant("entry_2", "Hello!")))) + XCTAssertEqual(bubbles("Hi", in: model), 2, "an entry settles one message for good") + XCTAssertEqual(model.outgoing.map(\.key), [second.key]) + + transport.queueSession(.success(try session(.running, seq: 6, entries: [user("entry_1", "Hi"), assistant("entry_2", "Hello!")]))) + await model.load() + XCTAssertEqual(transport.sendKeys.suffix(2), [second.key, second.key], "the deferred Hi went out under its key") + XCTAssertEqual(bubbles("Hi", in: model), 2) + _ = await model.apply(event(seq: 7, .entry(user("entry_3", "Hi")))) + XCTAssertEqual(model.outgoing, []) + XCTAssertEqual(bubbles("Hi", in: model), 2) + } + + func testADeferredMessageHoldsBackTheOnesBehindIt() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.idle)) + transport.failNextSend(with: URLError(.networkConnectionLost)) + _ = await model.send("First") + _ = await model.send("Second") + XCTAssertEqual(transport.sendTexts, ["First"], "nothing overtakes a message waiting to retry") + XCTAssertEqual(model.outgoing.map(\.text), ["First", "Second"]) + + transport.queueSession(.success(try session(.idle, seq: 5))) + await model.load() + XCTAssertEqual(transport.sendTexts, ["First", "First", "Second"]) + XCTAssertEqual(model.outgoing.map { model.look(of: $0) }, [.sent, .sent]) + } + + func testWhileTheBotWorksAMessageIsQueuedInItsModeAndTheMacsBubbleReplacesIt() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.running, activity: AidenBotSessionActivity(phase: "reading", startedAt: started))) + transport.holdSend = true + let sending = Task { await model.send("Keep it vegetarian.", whenBusy: .steer) } + try await waitUntil({ transport.isHoldingSend }, what: "the request") + XCTAssertEqual(model.look(of: try XCTUnwrap(model.outgoing.first)), .queued(.steer), "Queued at once, before the Mac answers") + XCTAssertEqual(model.statusLine(nowMs: started)?.display.word, "Reading", "the running turn keeps its line") + + // The Mac's Queued frame can arrive before its receipt. + let onMac = AidenBotQueuedMessage(submissionId: "17", mode: .steer, text: "Keep it vegetarian.") + _ = await model.apply(event(seq: 5, .queued([onMac]))) + XCTAssertEqual(bubbles("Keep it vegetarian.", in: model), 1) + transport.releaseSend() + _ = await sending.value + XCTAssertEqual(bubbles("Keep it vegetarian.", in: model), 1) + + // The turn takes it in: the queue empties, and then its entry lands. + _ = await model.apply(event(seq: 6, .queued([]))) + XCTAssertEqual(bubbles("Keep it vegetarian.", in: model), 1, "between the Mac's frames it keeps its bubble") + XCTAssertEqual(model.look(of: try XCTUnwrap(model.outgoing.first)), .sent) + _ = await model.apply(event(seq: 7, .entry(user("entry_9", "Keep it vegetarian.", submissionId: "17")))) + XCTAssertEqual(bubbles("Keep it vegetarian.", in: model), 1) + XCTAssertEqual(model.outgoing, []) + } + + /// FR-m5: the queued list caps at 20 and the Mac leaves out what it can't + /// classify, so one snapshot that doesn't show a taken message is no proof. + func testATakenMessageASnapshotDoesNotShowStaysThroughTheGraceAndThenGoes() async throws { + var clock = Date() + let reading = AidenBotSessionActivity(phase: "reading", startedAt: started) + let transport = FakeBotSessionTransport() + transport.queueSession(.success(try session(.running, activity: reading))) + let model = model(transport, now: { clock }) + await model.load() + _ = await model.send("Also add a shopping list.", whenBusy: .queue) + XCTAssertEqual(model.outgoing.first?.status, .sent(submissionId: "17")) + + let full = (1...20).map { AidenBotQueuedMessage(submissionId: "q\($0)", mode: .queue, text: "Other \($0)") } + _ = await model.apply(event(seq: 5, .snapshot(try session(.running, seq: 5, activity: reading, queued: full)))) + XCTAssertEqual(bubbles("Also add a shopping list.", in: model), 1, "a full queue may simply not list it") + clock = clock.addingTimeInterval(AidenBotSessionModel.unheldGrace / 2) + _ = await model.apply(event(seq: 6, .snapshot(try session(.running, seq: 6, activity: reading, queued: full)))) + XCTAssertEqual(bubbles("Also add a shopping list.", in: model), 1, "a later snapshot inside the grace keeps it") + + clock = clock.addingTimeInterval(AidenBotSessionModel.unheldGrace) + _ = await model.apply(event(seq: 7, .activity(AidenBotSessionActivity(phase: "searching", startedAt: started)))) + XCTAssertEqual(bubbles("Also add a shopping list.", in: model), 0, "nothing held it for the whole grace: it goes") + } + + func testATakenMessageTheMacShowsAgainAfterAMissIsHeldAgain() async throws { + var clock = Date() + let reading = AidenBotSessionActivity(phase: "reading", startedAt: started) + let transport = FakeBotSessionTransport() + transport.queueSession(.success(try session(.running, activity: reading))) + let model = model(transport, now: { clock }) + await model.load() + _ = await model.send("Also add a shopping list.", whenBusy: .queue) + + _ = await model.apply(event(seq: 5, .snapshot(try session(.running, seq: 5, activity: reading)))) + XCTAssertEqual(bubbles("Also add a shopping list.", in: model), 1) + // The Mac lists it after all, then the turn takes it in. + let onMac = AidenBotQueuedMessage(submissionId: "17", mode: .queue, text: "Also add a shopping list.") + _ = await model.apply(event(seq: 6, .queued([onMac]))) + _ = await model.apply(event(seq: 7, .queued([]))) + clock = clock.addingTimeInterval(AidenBotSessionModel.unheldGrace * 2) + _ = await model.apply(event(seq: 8, .activity(AidenBotSessionActivity(phase: "searching", startedAt: started)))) + XCTAssertEqual(bubbles("Also add a shopping list.", in: model), 1, "held again: its entry is on the way") + _ = await model.apply(event(seq: 9, .entry(user("entry_9", "Also add a shopping list.", submissionId: "17")))) + XCTAssertEqual(bubbles("Also add a shopping list.", in: model), 1) + XCTAssertEqual(model.outgoing, []) + } + + /// R21: a request frozen while the cached capability said Steer/Queue goes + /// again byte for byte, even after `/server` says this Mac doesn't offer them. + func testASendFrozenUnderTheCachedCapabilityReplaysExactly() async throws { + let reading = AidenBotSessionActivity(phase: "reading", startedAt: started) + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.running, activity: reading)) + transport.failNextSend(with: URLError(.networkConnectionLost)) + _ = await model.send("Keep it vegetarian.", whenBusy: .steer) + XCTAssertEqual(transport.sendModes, [.steer]) + + model.activitySupported = false + _ = await model.apply(event(seq: 5, .snapshot(try session(.running, seq: 5, activity: reading)))) + try await waitUntil({ transport.sendKeys.count == 2 }, what: "the replay") + XCTAssertEqual(transport.sendKeys[0], transport.sendKeys[1], "the same key") + XCTAssertEqual(transport.sendModes, [.steer, .steer], "the same body, whenBusy included") + XCTAssertEqual(transport.sendTexts, ["Keep it vegetarian.", "Keep it vegetarian."]) + } + + func testAnEntryThatArrivesBeforeTheReceiptHidesTheMessageUntilTheReceiptSettlesIt() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.idle)) + transport.holdSend = true + let sending = Task { await model.send("Hi") } + try await waitUntil({ transport.isHoldingSend }, what: "the request") + _ = await model.apply(event(seq: 5, .entry(user("entry_1", "Hi")))) + XCTAssertEqual(bubbles("Hi", in: model), 1, "the entry stands in while the request is unsettled") + XCTAssertTrue(model.isAwaitingTurn, "the unsettled message is kept until its receipt") + transport.releaseSend() + _ = await sending.value + XCTAssertEqual(bubbles("Hi", in: model), 1) + XCTAssertFalse(model.isAwaitingTurn) + } + + // MARK: The outbox + + func testBeforeTheConnectionAMessageWaitsAndGoesOutOnConnectWithItsKey() async throws { + let transport = FakeBotSessionTransport() + let model = model(transport) + XCTAssertTrue(model.showsSkeleton, "skeleton bubbles, not a spinner") + XCTAssertTrue(model.isConnecting) + XCTAssertTrue(model.canSend, "the composer never waits for the connection") + + let taken = await model.send("Plan my week.") + XCTAssertTrue(taken) + XCTAssertEqual(model.outgoing.map(\.text), ["Plan my week."]) + XCTAssertEqual(transport.sendKeys, [], "nothing goes out before the Mac answers") + XCTAssertEqual(model.statusLine(nowMs: started)?.display.word, "Getting ready") + + transport.queueSession(.success(try session(.idle))) + await model.load() + XCTAssertFalse(model.showsSkeleton) + XCTAssertFalse(model.isConnecting) + XCTAssertEqual(transport.sendKeys.count, 1) + XCTAssertEqual(model.outgoing.first?.key, transport.sendKeys.first) + XCTAssertEqual(model.outgoing.first?.status, .sent(submissionId: "17")) + } + + func testAMessageForABotThatNeedsAModelIsMarkedNotSentOnConnect() async throws { + let transport = FakeBotSessionTransport() + let model = model(transport) + _ = await model.send("Hello") + transport.queueSession(.success(try session(.needsModel))) + await model.load() + XCTAssertEqual(transport.sendKeys, []) + XCTAssertEqual(model.outgoing.map { model.look(of: $0) }, [.failed], "never dropped silently") + let again = await model.send("Again") + XCTAssertFalse(again, "a Bot that needs a model takes nothing new") + } + + func testALostConnectionKeepsTheMessageAndItsKeyUntilTheMacIsBack() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.idle)) + transport.failNextSend(with: URLError(.networkConnectionLost)) + _ = await model.send("Plan my week.") + XCTAssertEqual(model.look(of: try XCTUnwrap(model.outgoing.first)), .sending, "it keeps its place") + XCTAssertEqual(transport.sendKeys.count, 1) + + transport.queueSession(.success(try session(.idle, seq: 5))) + await model.load() + XCTAssertEqual(transport.sendKeys.count, 2) + XCTAssertEqual(transport.sendKeys[0], transport.sendKeys[1], "the same message, the same key") + XCTAssertEqual(model.outgoing.first?.status, .sent(submissionId: "17")) + } + + func testWhileConnectedAMessageRetriesAFewTimesUnderItsKeyAndThenSaysSo() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.idle), retryDelays: [.milliseconds(20)]) + for _ in 0.. + if action == "resume" { + transport.holdResume = true + transport.queueResume(.failure(try serverError(status: 409, code: "invalid_request"))) + task = Task { await model.resume() } + try await waitUntil({ transport.isHoldingResume }, what: "the Resume request") + } else { + transport.dismissGate.hold() + transport.queueDismiss(.failure(try serverError(status: 409, code: "invalid_request"))) + task = Task { await model.dismiss() } + try await waitUntil({ transport.dismissGate.isHolding }, what: "the Dismiss request") + } + XCTAssertFalse(model.isInterrupted, action) + _ = await model.apply(event(seq: 5, .entry(assistant("entry_1", "Still here.")))) + if action == "resume" { transport.releaseResume() } else { transport.dismissGate.release() } + await task.value + XCTAssertTrue(model.isInterrupted, "\(action): an unrelated frame doesn't keep a failed action's look") + XCTAssertNotNil(model.errorMessage, action) + } + + // A state the Mac wrote meanwhile is its truth, and stays. + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.interrupted)) + transport.holdResume = true + transport.queueResume(.failure(URLError(.notConnectedToInternet))) + let resuming = Task { await model.resume() } + try await waitUntil({ transport.isHoldingResume }, what: "the Resume request") + _ = await model.apply(event(seq: 5, .state(try AidenBotSessionStateView(state: .running)))) + transport.releaseResume() + await resuming.value + XCTAssertEqual(model.state, .running) + XCTAssertFalse(model.isInterrupted) + } + + func testAFailedQuestionOrApprovalAnswerComesBackEvenAfterAnUnrelatedFrame() async throws { + let question = #"{"waitId":"5f0c1a2e-7b3d-4e9a-8c61-0d2e3f4a5b6c","toolCallId":"call_1","questions":[{"question":"Which colour should the banner use?","header":"Colour","multiSelect":false,"options":[{"label":"Blue","description":"Calm and cool."},{"label":"Red","description":"Loud and warm."}]}]}"# + let approval = #"{"waitId":"8d1e2f3a-4b5c-4d6e-9f70-a1b2c3d4e5f6","toolCallId":"call_2","toolName":"mcp__mail__send_email","summary":"Send an email to dana@example.com","canAllow":true}"# + for card in ["question", "approval"] { + let json = """ + {"botId":"bot_fixture_01","epoch":"epoch_1","seq":0,"state":"running","interrupted":false,"entries":[],"hasOlder":false,"question":\(card == "question" ? question : "null"),"approval":\(card == "approval" ? approval : "null")} + """ + let transport = QuestionTransport(snapshot: try JSONDecoder().decode(AidenBotSession.self, from: Data(json.utf8))) + let model = AidenBotSessionModel(botID: botID, transport: transport) + await model.load() + transport.answerGate.hold() + let task: Task + if card == "question" { + transport.nextAnswerError = URLError(.notConnectedToInternet) + task = Task { + await model.answerQuestion(AidenQuestionRespondRequest(cancelled: false, answers: [.option(questionIndex: 0, answer: "Blue")])) + } + } else { + transport.nextApprovalError = URLError(.notConnectedToInternet) + task = Task { await model.answerApproval(.allow) } + } + try await waitUntil({ transport.answerGate.isHolding }, what: "the answer") + XCTAssertNil(card == "question" ? model.question?.waitId : model.approval?.waitId, "\(card) goes at once") + _ = await model.apply(AidenBotSessionEvent(botId: botID, epoch: "epoch_1", seq: 1, kind: .partial("Working on it"))) + transport.answerGate.release() + await task.value + if card == "question" { + XCTAssertNotNil(model.question, "the question comes back after an unrelated frame") + } else { + XCTAssertNotNil(model.approval, "the approval comes back after an unrelated frame") + } + XCTAssertNotNil(model.errorMessage, card) + } + } + + func testAStreamThatLosesAccessPurgesAndSaysSoWithoutTheRefreshOverwritingIt() async throws { + let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) + let (store, _) = temporaryStore() + let transport = FakeBotSessionTransport() + let model = model(transport, key: key, store: store) + _ = await model.send("Plan my week.") + try await stored(store, key, count: 1) + transport.queueStreamFailure(try serverError(status: 401, code: "authentication_required")) + let listener = Task { await model.listen() } + try await waitUntil({ model.outgoing.isEmpty }, what: "the purge") + listener.cancel() + await listener.value + XCTAssertEqual(model.errorMessage, "That message wasn’t sent. Please try again.") + try await stored(store, key, count: 0) + + transport.queueSession(.failure(try serverError(status: 401, code: "authentication_required"))) + await model.load() // the refresh fails with 401 too: the notice stays + XCTAssertEqual(model.errorMessage, "That message wasn’t sent. Please try again.") + } + + func testAnEchoNamingAnotherSubmissionGivesWayWhenTheReceiptDisagrees() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.idle)) + transport.holdSend = true + let sending = Task { await model.send("Hi") } + try await waitUntil({ transport.isHoldingSend }, what: "the request") + _ = await model.apply(event(seq: 5, .entry(user("entry_1", "Hi", submissionId: "99")))) + XCTAssertEqual(bubbles("Hi", in: model), 1) + transport.releaseSend() // the receipt names 17 + _ = await sending.value + XCTAssertEqual(model.outgoing.map(\.status), [.sent(submissionId: "17")], "entry_1 was submission 99's") + XCTAssertEqual(bubbles("Hi", in: model), 2) + _ = await model.apply(event(seq: 6, .entry(user("entry_2", "Hi", submissionId: "17")))) + XCTAssertEqual(model.outgoing, []) + XCTAssertEqual(bubbles("Hi", in: model), 2) + } + + func testAMessageTheMacIsStillTakingKeepsItsKey() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.idle)) + transport.failNextSend(with: try serverError(status: 409, code: "idempotency_in_flight")) + _ = await model.send("Plan my week.") + XCTAssertEqual(model.look(of: try XCTUnwrap(model.outgoing.first)), .sending, "not Couldn't send") + transport.queueSession(.success(try session(.idle, seq: 5))) + await model.load() + XCTAssertEqual(transport.sendKeys.count, 2) + XCTAssertEqual(transport.sendKeys[0], transport.sendKeys[1]) + } + + func testAStopPressedBeforeAnyActivityClearsOnTheNewRunsFirstActivity() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.running)) + transport.holdStop = true + transport.queueStop(.success(try AidenBotSessionStateView(state: .running))) + let stopping = Task { await model.stop() } + try await waitUntil({ transport.isHoldingStop }, what: "the Stop request") + XCTAssertEqual(model.statusLine(nowMs: started)?.display.word, "Stopping") + _ = await model.apply(event(seq: 5, .activity(AidenBotSessionActivity(phase: "searching", startedAt: started)))) + XCTAssertFalse(model.isStopping) + XCTAssertEqual(model.statusLine(nowMs: started)?.display.word, "Searching the web") + transport.releaseStop() + await stopping.value + XCTAssertEqual(model.statusLine(nowMs: started)?.display.word, "Searching the web") + } + + // MARK: Fix round 3 + + func testOutOfTriesAMessageEchoedByAnotherSubmissionSaysCouldntSendAndStays() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.idle), retryDelays: [.milliseconds(20)]) + for _ in 0.. [[String: Any]] { + let files = (FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)?.compactMap { $0 as? URL } ?? []) + .filter { $0.pathExtension == "json" } + guard let file = files.first, + let envelope = try JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any] else { return [] } + return envelope["messages"] as? [[String: Any]] ?? [] + } + + private func waitForDisk(_ root: URL, what: String, _ condition: ([[String: Any]]) -> Bool) async throws { + let deadline = Date().addingTimeInterval(5) + while !condition((try? diskRecords(root)) ?? []) { + guard Date() < deadline else { return XCTFail("Timed out waiting for \(what) on disk") } + try await Task.sleep(for: .milliseconds(10)) + } + } + + /// Runs one message out of tries while another submission's same-text entry lands mid-flight. + private func failBesideAnotherSubmission(_ model: AidenBotSessionModel, _ transport: FakeBotSessionTransport) async throws { + for _ in 0.. 0, Date() < deadline { try await Task.sleep(for: .milliseconds(10)) } + let running = await live.running + XCTAssertEqual(running, 0, "every request a cancelled caller started was cancelled with it") + try await Task.sleep(for: .milliseconds(50)) + let neverStarted = await untouched.started + XCTAssertEqual(neverStarted, 0, "an already-cancelled caller never starts the request") + } + + func testAnUnpairStopsAPairingWhoseOnlyWriteIsStillWaiting() async throws { + let (store, _) = temporaryStore() + let transport = FakeBotSessionTransport() + var keys: [AidenBotSessionKey] = [] + for index in 0..<40 { + let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_\(index)", botID: botID) + keys.append(key) + let open = model(transport, key: key, store: store) + _ = await open.send("First") // handed to the store, maybe not made yet + await store.purge(instanceId: "mac_a") + _ = await open.send("Second") // the chat is still on screen after the unpair + } + try await Task.sleep(for: .milliseconds(300)) + for key in keys { + let left = await store.load(key) + XCTAssertEqual(left, [], "\(key.deviceId): a late write never brings the file back") + } + } + + func testAnOutboxFileThatCantBeReadIsSetAsideAndTheChatSaysSo() async throws { + let transport = FakeBotSessionTransport() + let damages: [(String, (Data) throws -> Data)] = [ + ("garbage", { _ in Data("not an outbox".utf8) }), + ("a newer version", { original in + var envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: original) as? [String: Any]) + envelope["version"] = 2 + return try JSONSerialization.data(withJSONObject: envelope) + }), + ("oversized", { _ in Data(repeating: 0x20, count: 2 * AidenBotOutboxStore.maxBytes + 1) }), + ] + for (name, damage) in damages { + let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) + let (store, root) = temporaryStore() + let writer = model(transport, key: key, store: store) + _ = await writer.send("Plan my week.") + try await stored(store, key, count: 1) + writer.release() + let files = { (FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)?.compactMap { $0 as? URL } ?? []) + .filter { !$0.hasDirectoryPath } } + let file = try XCTUnwrap(files().first, name) + let damaged = try damage(Data(contentsOf: file)) + try damaged.write(to: file) + + let reader = model(transport, key: key, cache: AidenBotSessionCache(), store: store) + await reader.load() + XCTAssertEqual(reader.restoreNotice, AidenBotSessionCopy.outboxUnrestorable, name) + XCTAssertEqual(reader.outgoing, [], name) + let aside = files().filter { $0.pathExtension != "json" } + XCTAssertEqual(aside.count, 1, "\(name): set aside, never deleted") + XCTAssertEqual(try aside.first.map { try Data(contentsOf: $0) }, damaged, "\(name): kept as it was") + + // The chat writes its own outbox afresh beside it. + _ = await reader.send("Again") + try await stored(store, key, count: 1) + XCTAssertEqual(files().filter { $0.pathExtension != "json" }.count, 1, name) + } + } + + func testAnOutboxFileThatCantBeOpenedIsSetAsideAndNeverOverwritten() async throws { + let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) + let (store, root) = temporaryStore() + let transport = FakeBotSessionTransport() + let writer = model(transport, key: key, store: store) + _ = await writer.send("Plan my week.") + try await stored(store, key, count: 1) + writer.release() + let files = { (FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)?.compactMap { $0 as? URL } ?? []) + .filter { !$0.hasDirectoryPath } } + let file = try XCTUnwrap(files().first) + let size = try XCTUnwrap(FileManager.default.attributesOfItem(atPath: file.path)[.size] as? Int) + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: file.path) + defer { + for unreadable in files() { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: unreadable.path) } + } + + let reader = model(transport, key: key, cache: AidenBotSessionCache(), store: store) + await reader.load() + XCTAssertEqual(reader.restoreNotice, AidenBotSessionCopy.outboxUnrestorable) + XCTAssertEqual(reader.outgoing, []) + let aside = try XCTUnwrap(files().first { $0.pathExtension != "json" }, "set aside, never deleted") + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath: aside.path)[.size] as? Int, size) + + // The chat's next write starts a new file; the one it couldn't read is left as it was. + _ = await reader.send("Again") + try await stored(store, key, count: 1) + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath: aside.path)[.size] as? Int, size) + XCTAssertEqual(files().filter { $0.pathExtension != "json" }.count, 1) + } + + func testAHungSessionRequestGivesUpInTime() async throws { + let transport = FakeBotSessionTransport() + transport.sessionGate.hold() + transport.queueSession(.success(try session(.idle))) + let model = model(transport, requestLimit: .milliseconds(200)) + let began = Date() + await model.load() + XCTAssertLessThan(Date().timeIntervalSince(began), 3, "the chat doesn't wait on URLSession's timeout") + XCTAssertFalse(model.hasLoaded) + XCTAssertNotNil(model.errorMessage) + transport.sessionGate.releaseAll() + } +} + +/// A working Bot's face follows the shared `glance` keyframes (docs/activity-marks.md). +final class AidenBotFaceGlanceTests: XCTestCase { + /// R19: a working Bot always keeps a visible cue, and only a face that may move glances. + func testAWorkingBotAlwaysShowsACueAndOnlyAMovingFaceGlances() { + XCTAssertEqual(aidenBotWorkingCue(working: true, hasPhoto: false, motionAllowed: true), AidenBotWorkingCue(glances: true, badge: false)) + XCTAssertEqual( + aidenBotWorkingCue(working: true, hasPhoto: false, motionAllowed: false), + AidenBotWorkingCue(glances: false, badge: true), + "under Reduce Motion or Low Power a face wears the still badge" + ) + XCTAssertEqual(aidenBotWorkingCue(working: true, hasPhoto: true, motionAllowed: true), AidenBotWorkingCue(glances: false, badge: true)) + XCTAssertEqual(aidenBotWorkingCue(working: true, hasPhoto: true, motionAllowed: false), AidenBotWorkingCue(glances: false, badge: true)) + XCTAssertEqual(aidenBotWorkingCue(working: false, hasPhoto: false, motionAllowed: true), .none) + XCTAssertEqual(aidenBotWorkingCue(working: false, hasPhoto: true, motionAllowed: false), .none) + } + + func testTheEyesLookLeftThenRightAndBlinkOnTheGlanceTimeline() { + XCTAssertEqual(aidenBotFaceGlancePose(at: 0), .still, "t = 0 is the still pose Reduce Motion shows") + // 1.28 s is 32% of the 4 s period: held at translateX −2.6 of 24. + XCTAssertEqual(aidenBotFaceGlancePose(at: 1.28).dx, -2.6 / 24, accuracy: 0.0005) + // 2.4 s is 60%: held at +2.6. + XCTAssertEqual(aidenBotFaceGlancePose(at: 2.4).dx, 2.6 / 24, accuracy: 0.0005) + // 3.6 s is 90%: the blink's lowest point. + XCTAssertEqual(aidenBotFaceGlancePose(at: 3.6).blink, 0.1, accuracy: 0.0005) + XCTAssertEqual(aidenBotFaceGlancePose(at: 4).dx, 0, accuracy: 0.0005, "the timeline loops") + } +} + +/// The chat's quiet connection hint (R14): it never blocks the chat, and it +/// stays out of the way while the status line already says "Reconnecting". +@MainActor +final class AidenBotConnectionHintTests: XCTestCase { + private let botID = "bot_fixture_01" + private let started = 1_760_450_000_000 + + private func session(_ state: AidenBotSessionStateKind, epoch: String = "epoch_a", seq: Int = 4) throws -> AidenBotSession { + AidenBotSession( + botId: botID, + epoch: epoch, + seq: seq, + stateView: try AidenBotSessionStateView(state: state), + partial: nil, + entries: [], + hasOlder: false, + activity: state == .running ? AidenBotSessionActivity(phase: "reading", startedAt: started) : nil, + queued: [] + ) + } + + private func waitUntil(_ condition: () -> Bool, what: String) async throws { + let deadline = Date().addingTimeInterval(5) + while !condition() { + guard Date() < deadline else { return XCTFail("Timed out waiting for \(what)") } + try await Task.sleep(for: .milliseconds(20)) + } + } + + func testAChatSaysConnectingUntilTheMacAnswersAndAFailedFirstLoadOffersTryAgain() async throws { + let transport = FakeBotSessionTransport() + let model = AidenBotSessionModel(botID: botID, transport: transport) + XCTAssertEqual(aidenBotConnectionHint(for: model, nowMs: started), .connecting, "before the first snapshot") + + await model.load() // nothing queued: the Mac can't be reached + guard case .unreachable = aidenBotConnectionHint(for: model, nowMs: started) else { + return XCTFail("a failed first load is a quiet hint with Try again, not a blocking screen") + } + + transport.queueSession(.success(try session(.idle))) + await model.load() + XCTAssertNil(aidenBotConnectionHint(for: model, nowMs: started), "connected: no hint") + } + + func testADroppedStreamSaysConnectingOnlyWhenTheStatusLineDoesNotAlreadySayReconnecting() async throws { + for (state, expected) in [(AidenBotSessionStateKind.running, nil), (.idle, AidenBotConnectionHint.connecting)] { + let transport = FakeBotSessionTransport() + transport.queueSession(.success(try session(state))) + transport.queueStream([]) // the first connection drops at once + transport.queueStream([], staysOpen: true) + let model = AidenBotSessionModel(botID: botID, transport: transport) + model.activitySupported = true + await model.load() + let listener = Task { await model.listen() } + try await waitUntil({ model.isStreamDown }, what: "the drop") + XCTAssertEqual(aidenBotConnectionHint(for: model, nowMs: started), expected, "\(state)") + if state == .running { + XCTAssertEqual(model.statusLine(nowMs: started)?.display.word, "Reconnecting to your Mac") + } + listener.cancel() + await listener.value + } + } +} + +@MainActor +final class AidenBotChatActivityTests: XCTestCase { + private struct SaveFailure: Error {} + + private func detail(revision: String, _ activity: AidenBotChatActivity?) throws -> AidenBotDetail { + let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "contract", withExtension: "json")) + let shared = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + var object = try XCTUnwrap(shared["botDetail"] as? [String: Any]) + object["revision"] = revision + if let activity { object["chatActivity"] = ["level": activity.level.rawValue, "wording": activity.wording.rawValue] } + return try AidenRemoteJSONDecoder.decode(AidenBotDetail.self, from: JSONSerialization.data(withJSONObject: object)) + } + + private func conflict() throws -> Error { + let body = try AidenRemoteJSONDecoder.decode( + AidenRemoteErrorEnvelope.Body.self, + from: Data(#"{"code":"revision_conflict","message":"changed","requestId":"req_test_1","retryable":false}"#.utf8) + ) + return AidenRemoteClientError.server(statusCode: 409, body: body) + } + + /// Waits, at most 5 seconds, for a save to reach its hold. + private func untilSaving(_ condition: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(5) + while !condition() { + guard Date() < deadline else { + XCTFail("Timed out waiting for the save to start") + throw SaveFailure() + } + try await Task.sleep(for: .milliseconds(5)) + } + } + + /// Where a scripted save waits until the test lets it go, at most 5 seconds, + /// so a test that fails before letting it go never leaves a save hanging. + @MainActor + private final class Hold { + private(set) var isHeld = false + private var isReleased = false + + func wait() async throws { + isHeld = true + let deadline = Date().addingTimeInterval(5) + while !isReleased { + guard Date() < deadline else { + XCTFail("Timed out waiting for the test to let the save go") + throw SaveFailure() + } + try await Task.sleep(for: .milliseconds(5)) + } + } + + func release() { isReleased = true } + } + + /// A model whose Mac accepts every save, with the calls it saw. + private func model( + revision: String = "rev_1", + _ activity: AidenBotChatActivity? = nil, + save: @escaping @MainActor (AidenBotChatActivity, String) async throws -> AidenBotDetail, + refresh: @escaping @MainActor () async throws -> AidenBotDetail = { throw SaveFailure() }, + onSaved: @escaping @MainActor (AidenBotDetail) -> Void = { _ in }, + waitForConnection: (@MainActor (Duration) async -> Bool)? = nil, + isCurrent: @escaping @MainActor () -> Bool = { true }, + connectionHold: Duration = .seconds(8), + requestLimit: Duration = .seconds(12) + ) throws -> AidenBotChatActivityModel { + AidenBotChatActivityModel( + bot: try detail(revision: revision, activity), save: save, refresh: refresh, onSaved: onSaved, + waitForConnection: waitForConnection, isCurrent: isCurrent, + connectionHold: connectionHold, requestLimit: requestLimit + ) + } + + func testAPickWaitingForTheMacIsNeverSentToAnotherPairingThatConnects() async throws { + var paired = true + var saves = 0 + var saved: [AidenBotDetail] = [] + let model = try model(save: { next, _ in + saves += 1 + return try self.detail(revision: "rev_2", next) + }, onSaved: { saved.append($0) }, waitForConnection: { _ in + // The person switches Macs during the hold; the new pairing comes up connected. + paired = false + return true + }, isCurrent: { paired }) + + await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) + XCTAssertEqual(saves, 0, "nothing reaches the Mac that replaced the one this pick was for") + XCTAssertEqual(saved.count, 0) + XCTAssertEqual(model.value, .standard, "the pick goes back") + XCTAssertNil(model.errorMessage, "silently: that Bot is no longer on screen") + XCTAssertFalse(model.isSaving) + } + + func testASaveThatReturnsAfterThePairingChangedIsNeitherKeptNorReported() async throws { + var paired = true + let hold = Hold() + var saved: [AidenBotDetail] = [] + var refreshes = 0 + let model = try model(save: { next, _ in + try await hold.wait() + return try self.detail(revision: "rev_2", next) + }, refresh: { + refreshes += 1 + return try self.detail(revision: "rev_3", .standard) + }, onSaved: { saved.append($0) }, isCurrent: { paired }) + + let pick = Task { await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) } + try await untilSaving { hold.isHeld } + paired = false + hold.release() + await pick.value + + XCTAssertEqual(saved.count, 0, "the old Mac's Bot is never cached or reported under the new pairing") + XCTAssertEqual(refreshes, 0, "nothing more is asked of either Mac") + XCTAssertEqual(model.value, .standard) + XCTAssertNil(model.errorMessage) + XCTAssertFalse(model.isSaving) + } + + func testAPickSavesAtOnceWithTheBotsRevisionAndTheNextPickUsesTheNewOne() async throws { + var calls: [(AidenBotChatActivity, String)] = [] + var saved: [AidenBotDetail] = [] + let model = try model(save: { next, revision in + calls.append((next, revision)) + return try self.detail(revision: revision == "rev_1" ? "rev_2" : "rev_3", next) + }, onSaved: { saved.append($0) }) + XCTAssertEqual(model.value, .standard, "a Bot that never chose reads as Quiet, Follows the work") + + await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) + await model.pick(AidenBotChatActivity(level: .summary, wording: .thinking)) + XCTAssertEqual(calls.map(\.1), ["rev_1", "rev_2"]) + XCTAssertEqual(model.value, AidenBotChatActivity(level: .summary, wording: .thinking)) + XCTAssertEqual(saved.map(\.revision), ["rev_2", "rev_3"]) + + await model.pick(AidenBotChatActivity(level: .summary, wording: .thinking)) + XCTAssertEqual(calls.count, 2, "picking the current choice sends nothing") + } + + func testAFailedSaveReturnsToTheSavedChoiceAndSaysSo() async throws { + var revisions: [String] = [] + var fail = true + let model = try model(AidenBotChatActivity(level: .quiet, wording: .follows), save: { next, revision in + revisions.append(revision) + if fail { throw SaveFailure() } + return try self.detail(revision: "rev_2", next) + }) + await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) + XCTAssertEqual(model.value, AidenBotChatActivity(level: .quiet, wording: .follows)) + XCTAssertEqual(model.errorMessage, "Aiden couldn’t save that choice.") + XCTAssertFalse(model.isSaving) + + fail = false + await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) + XCTAssertEqual(revisions, ["rev_1", "rev_1"], "a failed save leaves the revision where it was") + XCTAssertEqual(model.value.level, .detailed) + } + + func testAConflictRefreshesTheBotAndTheNextPickCarriesTheRefreshedRevision() async throws { + var revisions: [String] = [] + var adopted: [AidenBotDetail] = [] + var conflicts = true + let macsChoice = AidenBotChatActivity(level: .summary, wording: .thinking) + let model = try model(save: { next, revision in + revisions.append(revision) + if conflicts { conflicts = false; throw try self.conflict() } + return try self.detail(revision: "rev_6", next) + }, refresh: { try self.detail(revision: "rev_5", macsChoice) }, onSaved: { adopted.append($0) }) + + await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) + XCTAssertEqual(model.value, macsChoice, "the radio shows what the Mac has now") + XCTAssertEqual(model.saveFailure, .conflict) + XCTAssertEqual(model.errorMessage, "This changed on your Mac. Showing what it has now.") + XCTAssertEqual(adopted.map(\.revision), ["rev_5"], "the screens behind hear about the Mac's version") + XCTAssertFalse(model.isSaving) + + await model.pick(AidenBotChatActivity(level: .detailed, wording: .thinking)) + XCTAssertEqual(revisions, ["rev_1", "rev_5"]) + XCTAssertEqual(model.value.level, .detailed) + } + + func testAConflictWhoseRefreshFailsStillRollsBack() async throws { + let model = try model(AidenBotChatActivity(level: .quiet, wording: .follows), save: { _, _ in throw try self.conflict() }) + await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) + XCTAssertEqual(model.value.level, .quiet) + XCTAssertEqual(model.saveFailure, .conflict) + } + + func testAPickShowsAtOnceAndASecondTapWhileSavingIsNeitherLostNorSentOnAStaleRevision() async throws { + var calls: [(AidenBotChatActivity, String)] = [] + let hold = Hold() + let model = try model(save: { next, revision in + calls.append((next, revision)) + if calls.count == 1 { try await hold.wait() } + return try self.detail(revision: "rev_\(calls.count + 1)", next) + }) + + let first = Task { await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) } + try await untilSaving { hold.isHeld } + XCTAssertEqual(model.value.level, .summary, "the choice shows before the Mac answers") + XCTAssertTrue(model.isSaving) + + await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) + XCTAssertEqual(model.value.level, .detailed, "a tap during a save shows at once too") + XCTAssertEqual(calls.count, 1, "it waits for the save in flight") + + hold.release() + await first.value + XCTAssertEqual(calls.map(\.1), ["rev_1", "rev_2"], "the follow-up carries the revision the first save returned") + XCTAssertEqual(model.value.level, .detailed) + XCTAssertFalse(model.isSaving) + XCTAssertNil(model.errorMessage) + } + + func testThreeTapsDuringOneSaveSendTheFirstAndTheLastOnly() async throws { + var calls: [AidenBotChatActivity] = [] + let hold = Hold() + let model = try model(save: { next, _ in + calls.append(next) + if calls.count == 1 { try await hold.wait() } + return try self.detail(revision: "rev_\(calls.count + 1)", next) + }) + let first = Task { await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) } + try await untilSaving { hold.isHeld } + await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) + await model.pick(AidenBotChatActivity(level: .detailed, wording: .thinking)) + await model.pick(AidenBotChatActivity(level: .quiet, wording: .thinking)) + hold.release() + await first.value + XCTAssertEqual(calls, [ + AidenBotChatActivity(level: .summary, wording: .follows), + AidenBotChatActivity(level: .quiet, wording: .thinking), + ]) + XCTAssertEqual(model.value, AidenBotChatActivity(level: .quiet, wording: .thinking)) + } + + func testPickingBackToTheSavedChoiceMidFlightEndsOnThatChoiceOnTheMacToo() async throws { + var calls: [AidenBotChatActivity] = [] + let hold = Hold() + let original = AidenBotChatActivity(level: .quiet, wording: .follows) + let model = try model(original, save: { next, _ in + calls.append(next) + if calls.count == 1 { try await hold.wait() } + return try self.detail(revision: "rev_\(calls.count + 1)", next) + }) + let first = Task { await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) } + try await untilSaving { hold.isHeld } + await model.pick(original) + XCTAssertEqual(model.value, original, "the page already shows the choice they went back to") + hold.release() + await first.value + XCTAssertEqual(calls.last, original, "the Mac ends on it too, not on the abandoned pick") + XCTAssertEqual(model.value, original) + } + + func testAFailedFollowUpReturnsToTheLastSavedChoice() async throws { + var count = 0 + let model = try model(save: { next, _ in + count += 1 + if count == 2 { throw SaveFailure() } + return try self.detail(revision: "rev_2", next) + }) + await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) + await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) + XCTAssertEqual(model.value.level, .summary, "back to what the Mac last confirmed") + XCTAssertEqual(model.errorMessage, "Aiden couldn’t save that choice.") + } + + func testAPickMadeWhileConnectingGoesOutOnceTheMacIsReachable() async throws { + var asked: [Duration] = [] + var calls = 0 + let model = try model(save: { next, _ in + calls += 1 + return try self.detail(revision: "rev_2", next) + }, waitForConnection: { hold in asked.append(hold); return true }, connectionHold: .seconds(8)) + await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) + XCTAssertEqual(asked, [.seconds(8)], "it holds the pick for about 8 seconds") + XCTAssertEqual(calls, 1) + XCTAssertNil(model.errorMessage) + } + + func testAPickThatNeverGetsAConnectionRollsBackWithoutSending() async throws { + var calls = 0 + let model = try model(save: { next, _ in + calls += 1 + return try self.detail(revision: "rev_2", next) + }, waitForConnection: { _ in false }) + await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) + XCTAssertEqual(calls, 0) + XCTAssertEqual(model.value, .standard) + XCTAssertEqual(model.saveFailure, .offline) + XCTAssertEqual(model.errorMessage, "Aiden couldn’t reach your Mac to save that choice.", "offline says so, not a generic failure") + } + + func testARequestOnADeadLinkIsBoundedAndTheNextPickStillUsesTheSameRevision() async throws { + var revisions: [String] = [] + var hang = true + let model = try model(save: { next, revision in + revisions.append(revision) + if hang { try await Task.sleep(for: .seconds(60)) } + return try self.detail(revision: "rev_2", next) + }, requestLimit: .milliseconds(50)) + let started = ContinuousClock.now + await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) + XCTAssertLessThan(ContinuousClock.now - started, .seconds(5), "it does not wait out the URLSession timeout") + XCTAssertEqual(model.value, .standard) + XCTAssertEqual(model.saveFailure, .offline) + + hang = false + await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) + XCTAssertEqual(revisions, ["rev_1", "rev_1"]) + XCTAssertEqual(model.value.level, .summary) + } + + func testPreviewsShowWhatEachLevelLooksLikeOnTheMac() { + let reading = 1 + let searching = 2 + let reply = AidenBotChatActivityPreview.durations.count - 1 + for level in AidenBotChatActivityLevel.allCases { + XCTAssertEqual(AidenBotChatActivityPreview.frame(index: reading, level: level).phase, "reading") + XCTAssertNil(AidenBotChatActivityPreview.frame(index: reply, level: level).phase, "the reply has landed") + } + XCTAssertEqual(AidenBotChatActivityPreview.frame(index: reading, level: .quiet).steps, []) + XCTAssertNil(AidenBotChatActivityPreview.frame(index: searching, level: .quiet).running) + XCTAssertEqual(AidenBotChatActivityPreview.frame(index: searching, level: .summary).steps, []) + // Detailed lists each finished step and the one in progress, which is the one the line names. + for index in 1...3 { + let frame = AidenBotChatActivityPreview.frame(index: index, level: .detailed) + XCTAssertEqual(frame.steps.count, index - 1, "finished steps") + XCTAssertNotNil(frame.running, "the step in progress shows as running") + } + XCTAssertNil(AidenBotChatActivityPreview.frame(index: 0, level: .detailed).running, "thinking has no step yet") + XCTAssertNil(AidenBotChatActivityPreview.frame(index: reply, level: .detailed).running) + + XCTAssertNil(AidenBotChatActivityPreview.frame(index: reply, level: .quiet).recap) + let summary = AidenBotChatActivityPreview.frame(index: reply, level: .summary) + XCTAssertNotNil(summary.recap) + XCTAssertFalse(summary.recapOpen) + XCTAssertTrue(summary.steps.isEmpty) + let detailed = AidenBotChatActivityPreview.frame(index: reply, level: .detailed) + XCTAssertNotNil(detailed.recap) + XCTAssertTrue(detailed.recapOpen) + XCTAssertEqual(detailed.steps.count, 3) + + XCTAssertEqual(AidenBotChatActivityPreview.frameIndex(elapsed: 0), 0) + XCTAssertEqual(AidenBotChatActivityPreview.frameIndex(elapsed: 1.5), 1) + XCTAssertEqual(AidenBotChatActivityPreview.frameIndex(elapsed: 6.5), reply) + XCTAssertEqual(AidenBotChatActivityPreview.frameIndex(elapsed: 8.8), 0, "the turn loops") + } + + func testReduceMotionStillFramesShowWhatMakesEachLevelDifferent() { + func still(_ level: AidenBotChatActivityLevel) -> AidenBotChatActivityPreview.Frame { + AidenBotChatActivityPreview.frame(index: AidenBotChatActivityPreview.stillIndex(for: level), level: level) + } + XCTAssertNotNil(still(.quiet).phase, "Quiet holds the line mid-turn") + XCTAssertNil(still(.quiet).recap) + XCTAssertNil(still(.summary).phase, "Summary holds the reply") + XCTAssertNotNil(still(.summary).recap, "with its recap above it, so it never looks like Quiet") + XCTAssertNotNil(still(.detailed).phase) + XCTAssertFalse(still(.detailed).steps.isEmpty && still(.detailed).running == nil, "Detailed shows its steps") + XCTAssertNotEqual(still(.quiet), still(.summary)) + XCTAssertNotEqual(still(.quiet), still(.detailed)) + XCTAssertNotEqual(still(.summary), still(.detailed)) + } + + func testTheAdvancedRowSummarizesTheChoice() { + XCTAssertEqual(AidenBotChatActivityCopy.summary(nil), "Quiet · Follows the work") + XCTAssertEqual(AidenBotChatActivityCopy.summary(AidenBotChatActivity(level: .detailed, wording: .thinking)), "Detailed · Always “Thinking”") + } + + func testAdvancedOffersChatActivityOnlyForAMacThatAnnouncesItAndAGrantedPhoneWithABot() { + func server(features: [String], grants: [AidenRemoteCapability]) -> AidenServer { + AidenServer( + protocolVersion: 1, instanceId: "i", name: "Mac", appVersion: "1.0", capabilities: grants, + serverCapabilities: grants, connectionMode: .lan, minimumClientVersion: nil, + serverTime: Date(timeIntervalSince1970: 0), features: features + ) + } + let announced = [AidenServer.botActivityFeature] + XCTAssertTrue(aidenBotShowsChatActivity(server: server(features: announced, grants: [.botActivity]), installation: nil, hasBaseline: true)) + XCTAssertFalse(aidenBotShowsChatActivity(server: server(features: [], grants: [.botActivity]), installation: nil, hasBaseline: true), "an older Mac") + XCTAssertFalse(aidenBotShowsChatActivity(server: server(features: announced, grants: []), installation: nil, hasBaseline: true), "no grant") + XCTAssertFalse(aidenBotShowsChatActivity(server: nil, installation: nil, hasBaseline: true), "no server read and nothing known") + XCTAssertFalse(aidenBotShowsChatActivity(server: server(features: announced, grants: [.botActivity]), installation: nil, hasBaseline: false), "no Bot detail yet") + } + + // MARK: Timeouts, offline copy and late saves + + func testASaveThatTimedOutButLandedShowsAsSavedWithNoError() async throws { + var reported: [String] = [] + let picked = AidenBotChatActivity(level: .detailed, wording: .follows) + let model = try model(save: { _, _ in + try await Task.sleep(for: .seconds(60)) + throw SaveFailure() + }, refresh: { try self.detail(revision: "rev_9", picked) }, onSaved: { reported.append($0.revision) }, requestLimit: .milliseconds(50)) + await model.pick(picked) + XCTAssertEqual(model.value, picked, "the Mac took it after all: no rollback") + XCTAssertNil(model.errorMessage) + XCTAssertNil(model.saveFailure) + XCTAssertEqual(reported, ["rev_9"], "the screens behind hear the Mac's version") + } + + func testAPickMadeWhileASaveTimedOutGoesOutOnceTheRefreshShowsThatSaveLanded() async throws { + var calls: [(AidenBotChatActivity, String)] = [] + let first = AidenBotChatActivity(level: .summary, wording: .follows) + let second = AidenBotChatActivity(level: .detailed, wording: .thinking) + let model = try model(save: { next, revision in + calls.append((next, revision)) + if calls.count == 1 { try await Task.sleep(for: .seconds(60)) } + return try self.detail(revision: "rev_3", next) + }, refresh: { try self.detail(revision: "rev_2", first) }, requestLimit: .milliseconds(400)) + + let pick = Task { await model.pick(first) } + try await untilSaving { calls.count == 1 } + await model.pick(second) + await pick.value + + XCTAssertEqual(calls.map(\.0), [first, second], "the second pick is not lost when the first one landed") + XCTAssertEqual(calls.map(\.1), ["rev_1", "rev_2"], "it goes out on the revision the refresh read") + XCTAssertEqual(model.value, second) + XCTAssertNil(model.errorMessage) + XCTAssertFalse(model.isSaving) + } + + func testASaveThatTimedOutAndDidNotLandRollsBackAndSaysTheMacWasUnreachable() async throws { + let model = try model(AidenBotChatActivity(level: .quiet, wording: .follows), save: { _, _ in + try await Task.sleep(for: .seconds(60)) + throw SaveFailure() + }, refresh: { try self.detail(revision: "rev_2", AidenBotChatActivity(level: .quiet, wording: .follows)) }, requestLimit: .milliseconds(50)) + await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) + XCTAssertEqual(model.value.level, .quiet) + XCTAssertEqual(model.saveFailure, .offline) + XCTAssertEqual(model.errorMessage, "Aiden couldn’t reach your Mac to save that choice.") + + // A refusal from the Mac is not "offline": it keeps the generic copy. + let refused = try self.model(save: { _, _ in throw SaveFailure() }) + await refused.pick(AidenBotChatActivity(level: .summary, wording: .follows)) + XCTAssertEqual(refused.saveFailure, .generic) + XCTAssertEqual(refused.errorMessage, "Aiden couldn’t save that choice.") + } + + func testASaveThatLandsAfterAdvancedClosedStillReachesTheScreensBehind() throws { + let report = AidenBotChatActivityReport() + var behind: [String] = [] + let first = try detail(revision: "rev_2", AidenBotChatActivity(level: .summary, wording: .follows)) + let second = try detail(revision: "rev_3", AidenBotChatActivity(level: .detailed, wording: .follows)) + let late = try detail(revision: "rev_4", AidenBotChatActivity(level: .quiet, wording: .thinking)) + + report.saved(first, reportBehind: { behind.append($0.revision) }) + report.saved(second, reportBehind: { behind.append($0.revision) }) + XCTAssertEqual(behind, [], "while Advanced is open it holds the newest") + report.disappeared(.advanced, reportBehind: { behind.append($0.revision) }) + XCTAssertEqual(behind, ["rev_3"], "closing reports the newest once") + report.saved(late, reportBehind: { behind.append($0.revision) }) + XCTAssertEqual(behind, ["rev_3", "rev_4"], "a pick confirmed after leaving still reports") + + // Advanced's own Save already reported the newest: closing reports nothing older. + let own = AidenBotChatActivityReport() + own.saved(first, reportBehind: { behind.append($0.revision) }) + own.reportedByAdvanced() + own.disappeared(.advanced, reportBehind: { behind.append($0.revision) }) + XCTAssertEqual(behind, ["rev_3", "rev_4"]) + } + + func testAPickConfirmedAfterTheSheetClosedFromChatActivityStillReachesTheScreensBehind() throws { + var behind: [String] = [] + let record: (AidenBotDetail) -> Void = { behind.append($0.revision) } + let picked = try detail(revision: "rev_2", AidenBotChatActivity(level: .summary, wording: .follows)) + let late = try detail(revision: "rev_3", AidenBotChatActivity(level: .detailed, wording: .follows)) + + // Chat activity is pushed over Advanced: still open, so the pick is held. + let report = AidenBotChatActivityReport() + report.appeared(.advanced) + report.disappeared(.advanced, replacedBy: .chatActivity, reportBehind: record) + report.appeared(.chatActivity) + report.saved(picked, reportBehind: record) + XCTAssertEqual(behind, []) + + // The sheet closes from Chat activity; Advanced never shows again. + report.disappeared(.chatActivity, reportBehind: record) + XCTAssertEqual(behind, ["rev_2"], "the held pick reaches the screens behind") + report.saved(late, reportBehind: record) + XCTAssertEqual(behind, ["rev_2", "rev_3"], "and one confirmed later goes straight behind") + + // Back from Chat activity to Advanced, then closing Advanced: reported once, at the end. + let back = AidenBotChatActivityReport() + behind = [] + back.disappeared(.advanced, replacedBy: .chatActivity, reportBehind: record) + back.appeared(.chatActivity) + back.appeared(.advanced) + back.disappeared(.chatActivity, reportBehind: record) + back.saved(picked, reportBehind: record) + XCTAssertEqual(behind, [], "Advanced still shows") + back.disappeared(.advanced, reportBehind: record) + XCTAssertEqual(behind, ["rev_2"]) + } + + func testTheRunningStepReadsInTheMacsPhaseWords() { + let running = (1...3).compactMap { AidenBotChatActivityPreview.frame(index: $0, level: .detailed).running } + XCTAssertEqual(running, ["Reading", "Searching the web", "Editing files"]) + } +} + +/// Counts the requests that started and those still running, for the request-race test. +private actor LiveRequests { + private(set) var running = 0 + private(set) var started = 0 + + /// A request that runs until it is cancelled. + func run() async { + started += 1 + running += 1 + try? await Task.sleep(for: .seconds(30)) + running -= 1 + } +} + +/// The Bot chat's small rules (Task 6 review): the Steer/Queue pill's mode, +/// following the transcript's end, and the waiting bubbles' text contrast. +final class AidenBotChatPolishTests: XCTestCase { + func testThePillStartsOnQueueKeepsItsChoiceWhileWorkingAndResetsWhenTheTurnEnds() { + XCTAssertEqual(aidenBotBusyMode(nil, isRunning: false), .queue) + XCTAssertEqual(aidenBotBusyMode(nil, isRunning: true), .queue, "a turn starts on Queue") + XCTAssertEqual(aidenBotBusyMode(.steer, isRunning: true), .steer, "a choice holds while the Bot works") + XCTAssertEqual(aidenBotBusyMode(.steer, isRunning: false), .queue, "and goes back to Queue when it ends") + } + + func testNewRowsFollowOnlyAReaderAtTheEnd() { + XCTAssertTrue(aidenBotFollowsTranscriptEnd(distanceFromEnd: 0)) + XCTAssertTrue(aidenBotFollowsTranscriptEnd(distanceFromEnd: -12), "an overscrolled end is the end") + XCTAssertTrue(aidenBotFollowsTranscriptEnd(distanceFromEnd: 60), "near the end counts") + XCTAssertFalse(aidenBotFollowsTranscriptEnd(distanceFromEnd: 600), "someone reading back stays put") + } + + /// WCAG 2 relative luminance, computed here from the hex, as the oracle. + private func contrast(_ a: String, _ b: String) -> Double { + func channel(_ hex: String, _ shift: Int) -> Double { + let value = Double((Int(hex.dropFirst(), radix: 16)! >> shift) & 0xFF) / 255 + return value <= 0.03928 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) + } + func luminance(_ hex: String) -> Double { + 0.2126 * channel(hex, 16) + 0.7152 * channel(hex, 8) + 0.0722 * channel(hex, 0) + } + let (x, y) = (luminance(a), luminance(b)) + return (max(x, y) + 0.05) / (min(x, y) + 0.05) + } + + func testWaitingBubblesKeepReadableTextInEveryThemeAndContrastSetting() { + for preset in AidenThemePresetID.allCases { + for scheme in [ColorScheme.light, .dark] { + let base = AidenThemeCatalog.palette(preset: preset, scheme: scheme) + // Settings' contrast slider runs 0–100 around a baseline of 45 (light) or 60 (dark). + for setting in [0, 25, 50, 75, 100] { + let palette = base.applyingContrast(setting, baseline: scheme == .dark ? 60 : 45) + let text = aidenBotPendingTextHex(palette) + XCTAssertGreaterThanOrEqual(contrast(text, palette.raisedHex), 4.5, "\(preset) \(scheme) \(setting)") + XCTAssertNotEqual(text.uppercased(), palette.foregroundHex.uppercased(), "still reads as waiting, not as sent") + } + } + } + } +} + +extension AidenBotOutboxStore { + /// Test seam: what a chat would restore, without the unreadable flag. + func load(_ key: AidenBotSessionKey) async -> [AidenBotOutgoingMessage] { await restore(key).messages } +} diff --git a/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift b/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift index acbb8b8ca..5d803baa6 100644 --- a/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift +++ b/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift @@ -3544,6 +3544,106 @@ final class AidenRemoteClientTests: XCTestCase { XCTAssertEqual(reloaded.activeInstallation?.hasNegotiatedAccess(to: .simulatorsMobile), true) } + /// Contract revision 30: `bot:activity` persists like the other negotiable + /// grants, survives a reload, and goes away when the Mac stops granting it. + @MainActor + func testNegotiatedBotActivityGrantPersistsAndDropsWhenTheMacStopsGrantingIt() async throws { + let client = makeClient() + AidenRemoteMockURLProtocol.handler = { request in + XCTAssertEqual(request.url?.path, "/api/aiden/v1/device/capabilities") + let body = try Self.jsonBody(request) + XCTAssertEqual(body["accepts"] as? [String], ["bot:activity"]) + return Self.response( + for: request, + status: 200, + json: "{\"capabilities\":[\"server:read\",\"workspace:read\",\"bot:activity\"]}" + ) + } + let negotiated = try await client.updateDeviceCapabilities(accepts: [.botActivity]) + XCTAssertEqual(negotiated, [.serverRead, .workspaceRead, .botActivity]) + + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + _ = try store.savePairing( + makeExchange(instanceId: "instance-activity", deviceId: "device-activity", credential: "credential-activity"), + trust: makeSystemTrust(), name: "Activity Mac" + ) + func server(grants: [AidenRemoteCapability]) -> AidenServer { + AidenServer( + protocolVersion: 1, instanceId: "instance-activity", name: "Activity Mac", + appVersion: "1.0", capabilities: grants, serverCapabilities: negotiated, + connectionMode: .lan, minimumClientVersion: nil, serverTime: Date(), + features: [AidenServer.botActivityFeature] + ) + } + XCTAssertEqual(store.activeInstallation?.hasNegotiatedAccess(to: .botActivity), false) + try store.updateNegotiatedDeviceCapabilities(negotiated, confirmedBy: server(grants: negotiated)) + XCTAssertEqual( + AidenInstallationStore(keychain: keychain).activeInstallation?.hasNegotiatedAccess(to: .botActivity), + true + ) + + // A /server refresh that no longer grants it narrows the stored grants. + try store.updateServer(server(grants: [.serverRead, .workspaceRead])) + let reloaded = try XCTUnwrap(AidenInstallationStore(keychain: keychain).activeInstallation) + XCTAssertEqual(reloaded.deviceCapabilities, [.serverRead, .workspaceRead]) + XCTAssertFalse(reloaded.hasNegotiatedAccess(to: .botActivity)) + } + + /// R21: the pairing remembers what it last knew of Bot activity (the Mac's token + /// and this device's grant), so a cold or offline open keeps the line, Steer/Queue + /// and the Chat activity row until `/server` answers; then `/server` decides. + @MainActor + func testTheLastKnownBotActivityStandsInUntilTheMacAnswers() throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + _ = try store.savePairing( + makeExchange(instanceId: "instance-cached", deviceId: "device-cached", credential: "credential-cached"), + trust: makeSystemTrust(), name: "Cached Mac" + ) + let negotiated: [AidenRemoteCapability] = [.serverRead, .workspaceRead, .botActivity] + func server(features: [String], grants: [AidenRemoteCapability]) -> AidenServer { + AidenServer( + protocolVersion: 1, instanceId: "instance-cached", name: "Cached Mac", + appVersion: "1.0", capabilities: grants, serverCapabilities: negotiated, + connectionMode: .lan, minimumClientVersion: nil, serverTime: Date(), features: features + ) + } + XCTAssertFalse(aidenBotActivitySupported(server: nil, installation: store.activeInstallation), "nothing known yet") + + try store.updateNegotiatedDeviceCapabilities( + negotiated, confirmedBy: server(features: [AidenServer.botActivityFeature], grants: negotiated) + ) + // A relaunch, offline: no /server answer yet. + let relaunched = try XCTUnwrap(AidenInstallationStore(keychain: keychain).activeInstallation) + XCTAssertTrue(aidenBotActivitySupported(server: nil, installation: relaunched)) + XCTAssertTrue(aidenBotShowsChatActivity(server: nil, installation: relaunched, hasBaseline: true), "the Chat activity row stays reachable") + + // Once /server answers, it decides, whatever is cached. + XCTAssertFalse(aidenBotActivitySupported(server: server(features: [], grants: negotiated), installation: relaunched)) + + // A Mac that stopped announcing the token is remembered that way too. + try store.updateServer(server(features: [], grants: negotiated)) + let downgraded = try XCTUnwrap(AidenInstallationStore(keychain: keychain).activeInstallation) + XCTAssertTrue(downgraded.hasNegotiatedAccess(to: .botActivity), "the grant alone is kept") + XCTAssertFalse(aidenBotActivitySupported(server: nil, installation: downgraded), "but without the token nothing shows") + } + + @MainActor + func testAPairingSavedBeforeRevision30ReadsAsNoBotActivity() throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + let saved = try store.savePairing( + makeExchange(instanceId: "instance-legacy", deviceId: "device-legacy", credential: "credential-legacy"), + trust: makeSystemTrust(), name: "Legacy Mac" + ) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(saved)) as? [String: Any]) + XCTAssertNotNil(object.removeValue(forKey: "botActivityAnnounced")) + let legacy = try JSONDecoder().decode(AidenInstallation.self, from: JSONSerialization.data(withJSONObject: object)) + XCTAssertFalse(legacy.botActivityAnnounced) + XCTAssertFalse(aidenBotActivitySupported(server: nil, installation: legacy)) + } + @MainActor func testServerRefreshCanNarrowButNeverWidenDeviceGrants() throws { let keychain = AidenRemoteMemoryKeychain() diff --git a/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift b/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift index eb919bd92..5ac2186b1 100644 --- a/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift +++ b/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift @@ -136,7 +136,7 @@ final class AidenRemotePhase0Tests: XCTestCase { from: data ) - XCTAssertEqual(fixture.contractRevision, 29) + XCTAssertEqual(fixture.contractRevision, 30) // Revision 19 run-control losers learn the winning decision; phones keep // their mobile-only grants, so the fixture never offers host capabilities. let runControlError = try XCTUnwrap(fixture.runControlError?.error) diff --git a/main/handlers/bots.ts b/main/handlers/bots.ts index 67fd59977..fee14f70d 100644 --- a/main/handlers/bots.ts +++ b/main/handlers/bots.ts @@ -17,6 +17,8 @@ import { dismissBotConnection, } from "../services/bot-runtime/bot-session-main.js"; import { botStarter } from "../services/bot-runtime/bot-starter-main.js"; +import { stopWithIntents } from "../services/bot-runtime/bot-turn-intents.js"; +import { botTurnIntents } from "../services/bot-runtime/bot-turn-intents-main.js"; import { parseAidenRemoteBotAvatarUploadRequest, type AidenRemoteBotAvatarUploadRequest, @@ -209,13 +211,8 @@ export function registerBotHandlers(): void { }); ipcMain.handle("bots:stop", async (_event, id: unknown) => { const botId = parseBotId(id); - botLiveProjection().notifyStopping(botId); - try { - return await (await botSessionRuntime()).stop(botId); - } catch (error) { - botLiveProjection().cancelStopping(botId); - throw error; - } + // "Stopping" in every open chat, on the Mac and on phones; taken back if the Stop fails. + return stopWithIntents(botTurnIntents, botId, async () => (await botSessionRuntime()).stop(botId)); }); ipcMain.handle("bots:createFromPreset", async (_event, input: unknown) => { const { presetId, access } = parseBotCreateFromPreset(input); diff --git a/main/services/aiden-remote-bot-session.test.ts b/main/services/aiden-remote-bot-session.test.ts index ed4a1015d..3036cfb67 100644 --- a/main/services/aiden-remote-bot-session.test.ts +++ b/main/services/aiden-remote-bot-session.test.ts @@ -9,24 +9,34 @@ import * as os from "node:os"; import * as path from "node:path"; import test, { after } from "node:test"; import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai/providers/faux"; +import { BOT_ACTIVITY_PHASES } from "../../renderer/shared/bot-activity.js"; import { BOT_PRESETS } from "../../renderer/shared/bot-presets.js"; import type { BotDefinition } from "../../renderer/shared/bots.js"; import { AidenRemoteBotService } from "./aiden-remote-bots.js"; import { AidenRemoteBotSessionService, projectAidenRemoteBotMemory, + projectBotSessionActivity, projectBotSessionEntries, projectBotSessionState, redactRemoteError, + tailWithinJsonBytes, type AidenRemoteBotSessionRuntime, } from "./aiden-remote-bot-session.js"; import type { AskUserQuestionV1 } from "../../renderer/shared/ask-user-question.js"; import { createBotApprovals } from "./bot-runtime/bot-approvals.js"; import { createBotQuestions } from "./bot-runtime/bot-questions.js"; +import { createBotTurnIntents } from "./bot-runtime/bot-turn-intents.js"; +import { createBotLiveProjection, createBotRunStarts } from "./bot-runtime/live-projection.js"; import { + AIDEN_REMOTE_BOT_SESSION_MAX_ENTRIES, + AIDEN_REMOTE_MAX_SSE_FRAME_BYTES, + parseAidenRemoteBotApproval, + parseAidenRemoteBotQuestion, parseAidenRemoteBotSession, parseAidenRemoteBotSessionEntry, parseAidenRemoteBotSessionEvent, + parseAidenSseFrames, } from "./aiden-remote-protocol.js"; import { BOT_NOTICE_ENTRY_KIND, @@ -124,6 +134,13 @@ function fakeRuntime(initial: BotSessionState) { async conversation() { return conversation as never; }, + async quietInputs(_botId, ids) { + // Every queued input is the person's own unless a test says otherwise. + return new Map(ids.map((id) => [id, false])); + }, + async placedSubmissions() { + return new Map(); + }, }; return { runtime, @@ -210,6 +227,43 @@ test("Stop ends a running turn but leaves an interrupted one for Dismiss", async assert.equal(view.state, "interrupted"); }); +test("a phone's Stop reads Stopping wherever the Bot is open, a Stop that fails takes it back, and Resume is the runtime's to announce", async () => { + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + let refuse = false; + const intents = createBotTurnIntents(); + const heard: string[] = []; + intents.onIntent((botId, intent) => void heard.push(`${intent} ${botId}`)); + const service = new AidenRemoteBotSessionService({ + bots: botService(), + runtime: async () => ({ + ...fake.runtime, + async stop(botId: string) { + if (refuse) throw new Error("The Bot is held by another Aiden."); + return fake.runtime.stop(botId); + }, + }), + turnIntents: intents, + }); + try { + assert.equal((await service.stop(DEVICE_ID, BOT_ID, "stop-request-0101", {})).state, "idle"); + assert.deepEqual(heard, [`stopping ${BOT_ID}`]); + await service.stop(DEVICE_ID, BOT_ID, "stop-request-0101", {}); + assert.deepEqual(heard, [`stopping ${BOT_ID}`], "a replayed request is not a second press"); + + fake.setState({ kind: "running", submissionId: "sub_2" }); + refuse = true; + await assert.rejects(service.stop(DEVICE_ID, BOT_ID, "stop-request-0102", {})); + assert.deepEqual(heard.slice(1), [`stopping ${BOT_ID}`, `stopCancelled ${BOT_ID}`]); + + // The session announces a Resume it carried out (`onResumed`); the route never guesses ahead of it. + fake.setState({ kind: "interrupted", submissionId: "sub_2" }); + assert.equal((await service.resume(DEVICE_ID, BOT_ID, RESUME_KEY, {})).state, "running"); + assert.equal(heard.length, 3); + } finally { + await service.close(); + } +}); + test("a session reopened on the host gets a new epoch, so clients refetch instead of folding", async () => { const fake = fakeRuntime({ kind: "idle" }); const service = sessionService(fake.runtime); @@ -467,14 +521,14 @@ test("routine errors reach phones without local paths or key-shaped secrets", () }); /** A conversation whose watch the test drives frame by frame. */ -function drivenConversation(initial: RawEntry[]) { +function drivenConversation(initial: RawEntry[], initialDocs: Record = {}) { let listener: ((view: unknown) => Promise) | undefined; let resolveClosed!: () => void; const closed = new Promise((resolve) => { resolveClosed = resolve; }); const conversation = { async watch() { return { - value: { entries: initial, docs: {} }, + value: { entries: initial, docs: initialDocs }, start(next: (view: unknown) => Promise) { listener = next; }, stop: async () => {}, closed, @@ -489,6 +543,10 @@ function drivenConversation(initial: RawEntry[]) { : { "pi.live": { generation: { message: { content: [{ type: "text", text: partial }] } } } }; await listener!({ entries, docs }); }, + /** One watch frame with exactly these docs (`pi.live`, `pi.inbox`). */ + async emitView(entries: RawEntry[], docs: Record) { + await listener!({ entries, docs }); + }, /** What a harness close (idle close, delete, quit) does to every watch. */ end: () => resolveClosed(), }; @@ -1192,3 +1250,1146 @@ test("answering a proposal from a phone is idempotent and an unknown proposal is routines.push({ id: "routine_1" } as BotRoutine); assert.deepEqual(await service.routineSuggestions(BOT_ID), { suggestions: [] }); }); + +// --------------------------------------------------------------------------- +// Contract revision 30: activity and queued frames (`bot:activity`) +// --------------------------------------------------------------------------- + +/** + * The Bot service of a host that announces `bot-activity-v1`: it also has the + * Chat activity gate (`supportsChatActivity`), which the session service needs + * alongside `turnIntents`. + */ +function activityBots() { + const bots = botService(); + return { + bot: (botId: string) => bots.bot(botId), + summaryOf: (botId: string) => bots.summaryOf(botId), + executeIdempotent: bots.executeIdempotent.bind(bots), + supportsChatActivity: true, + }; +} + +/** Serve one Bot's SSE stream to a device with these opt-ins; keep the raw data lines too. */ +async function openAudienceStream( + service: AidenRemoteBotSessionService, + audience: { cards?: boolean; activity?: boolean }, + botId = BOT_ID, +) { + const server = createServer((_request, response) => { + void service.openEvents("device_1", botId, response, () => {}, audience).catch(() => response.destroy()); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/`); + const frames: Frame[] = []; + const raw: string[] = []; + /** The SSE blocks as sent on the wire (id, event and data lines, without the blank separator). */ + const blocks: string[] = []; + const decoder = new TextDecoder(); + let buffer = ""; + const reading = (async () => { + for await (const chunk of response.body as unknown as AsyncIterable) { + buffer += decoder.decode(chunk, { stream: true }); + let index: number; + while ((index = buffer.indexOf("\n\n")) >= 0) { + const block = buffer.slice(0, index); + buffer = buffer.slice(index + 2); + const data = block.split("\n").find((line) => line.startsWith("data: ")); + if (!data) continue; + blocks.push(block); + raw.push(data.slice(6)); + frames.push(parseAidenRemoteBotSessionEvent(JSON.parse(data.slice(6))) as unknown as Frame); + } + } + })(); + return { + frames, + raw, + blocks, + async until(predicate: () => boolean, what: string) { + const deadline = Date.now() + 10_000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error(`Timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }, + async close() { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await reading.catch(() => undefined); + }, + }; +} + +/** The activity a device holds after each frame that carries one; null is "no turn running". */ +function activitiesOf(frames: Frame[]): Array<{ phase: string; app?: string; startedAt: number } | null> { + const activities: Array<{ phase: string; app?: string; startedAt: number } | null> = []; + for (const frame of frames) { + const activity = frame.type === "activity" + ? frame.payload.activity + : frame.type === "snapshot" ? frame.payload.session.activity : undefined; + if (activity !== undefined) activities.push(activity); + } + return activities; +} + +/** The phases a device saw, in order, with repeats folded. */ +function phasesOf(frames: Frame[]): Array { + const phases: Array = []; + for (const activity of activitiesOf(frames)) { + const phase = activity === null ? null : activity.phase; + if (phases.length === 0 || phases[phases.length - 1] !== phase) phases.push(phase); + } + return phases; +} + +function runningOn( + driven: ReturnType, + fake: ReturnType, + options: Partial[0]> = {}, +) { + return new AidenRemoteBotSessionService({ + bots: activityBots(), + runtime: async () => ({ ...fake.runtime, conversation: async () => driven.conversation as never }), + turnIntents: createBotTurnIntents(), + ...options, + }); +} + +const last = (items: readonly Item[]): Item | undefined => items[items.length - 1]; + +const gapless = (frames: Frame[]) => frames.map((frame) => frame.seq).every((seq, index, seqs) => seq === seqs[0]! + index); + +test("a phone holding bot:activity follows a real turn's phases and never sees what the tool was given", async () => { + const profileDir = mkdtempSync(path.join(os.tmpdir(), "aiden-remote-bot-activity-")); + durableRoots.push(profileDir); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + const command = countingTool("run_command", async () => { + await held; + return "done"; + }); + const planted = "cat /Users/dana/Documents/plan.md && echo sk-live-0123456789abcdef"; + const fauxModels = createFauxModels([ + fauxAssistantMessage([fauxToolCall("run_command", { text: planted })], { stopReason: "toolUse" }), + fauxAssistantMessage("Tidied."), + ]); + const runtime = await createBotSessionService({ + profileDir, + models: fauxModels.models, + extension: recordingDeps({ tools: [{ tool: command, replay: "unsafe" }] }), + resolveModel: async () => FAUX_MODEL_REF, + knownBotIds: async () => new Set([BOT_ID]), + }); + const service = new AidenRemoteBotSessionService({ + bots: activityBots(), + runtime: async () => runtime, + turnIntents: createBotTurnIntents(), + }); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the first snapshot"); + assert.equal(stream.frames[0]!.payload.session.activity, null, "an idle Bot is doing nothing"); + assert.deepEqual(stream.frames[0]!.payload.session.queued, []); + + await service.send(DEVICE_ID, BOT_ID, "send-request-activity-0001", { text: "Tidy my notes" }); + await stream.until(() => phasesOf(stream.frames).includes("command"), "Running a command"); + release(); + await stream.until( + () => last(phasesOf(stream.frames)) === null && phasesOf(stream.frames).length > 2, + "the turn to end", + ); + + for (const phase of phasesOf(stream.frames)) { + assert.ok(phase === null || (BOT_ACTIVITY_PHASES as readonly string[]).includes(phase), String(phase)); + } + for (const activity of activitiesOf(stream.frames)) { + if (activity) assert.deepEqual(Object.keys(activity).filter((key) => !["phase", "app", "startedAt"].includes(key)), []); + } + const wire = stream.raw.join("\n"); + for (const secret of ["/Users/dana", "plan.md", "sk-live", "echo "]) { + assert.equal(wire.includes(secret), false, `${secret} reached the phone`); + } + assert.ok(gapless(stream.frames), "gapless"); + } finally { + release(); + await stream.close(); + await service.close(); + await runtime.shutdown(); + } +}); + +test("a device without bot:activity gets the state it already has at each activity seq, and no activity keys", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const service = runningOn(driven, fake); + const activity = await openAudienceStream(service, { activity: true }); + const legacy = await openAudienceStream(service, {}); + try { + await activity.until(() => activity.frames.length >= 1, "the activity snapshot"); + await legacy.until(() => legacy.frames.length >= 1, "the legacy snapshot"); + assert.equal(activity.frames[0]!.payload.session.activity.phase, "preparing", "running with nothing from the model yet"); + assert.equal("activity" in legacy.frames[0]!.payload.session, false); + assert.equal("queued" in legacy.frames[0]!.payload.session, false); + + await driven.emitView([user(1, "Plan dinners")], { + "pi.live": { run: {}, tools: [{ callId: "call_1", name: "web_search", status: "running" }] }, + "pi.inbox": { items: [{ id: 18, mode: "followUp", content: [{ type: "text", text: "Also lunches." }] }] }, + }); + await activity.until(() => activity.frames.length >= 3, "the searching and queued frames"); + await legacy.until(() => legacy.frames.length >= 3, "the no-op frames"); + + assert.equal(activity.frames[1]!.type, "activity"); + assert.deepEqual(Object.keys(activity.frames[1]!.payload.activity).sort(), ["phase", "startedAt"]); + assert.equal(activity.frames[1]!.payload.activity.phase, "searching"); + assert.equal(activity.frames[2]!.type, "queued"); + for (const frame of legacy.frames.slice(1)) { + assert.equal(frame.type, "state"); + assert.deepEqual(frame.payload, { state: "running", interrupted: false }); + } + assert.deepEqual(legacy.frames.map((frame) => frame.seq), activity.frames.map((frame) => frame.seq), "every device stays on one seq"); + assert.ok(gapless(legacy.frames)); + assert.equal(legacy.raw.some((data) => /web_search|searching|Also lunches|"activity"|"queued"/u.test(data)), false); + const fetched = await service.session(BOT_ID); + assert.equal("activity" in fetched || "queued" in fetched, false, "a fetch without bot:activity has no new keys"); + } finally { + await activity.close(); + await legacy.close(); + await service.close(); + } +}); + +test("a phone that reconnects mid-run gets the phase and the waiting messages in its first snapshot", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const service = runningOn(driven, fake); + const first = await openAudienceStream(service, { activity: true }); + try { + await first.until(() => first.frames.length >= 1, "the first snapshot"); + await driven.emitView([user(1, "Plan dinners")], { + "pi.live": { run: { inputs: [1] }, tools: [{ callId: "call_1", name: "web_search", status: "running" }] }, + "pi.inbox": { items: [{ id: 18, mode: "steer", content: [{ type: "text", text: "Keep it vegetarian." }] }] }, + }); + await first.until(() => first.frames.some((frame) => frame.type === "queued"), "the queued frame"); + } finally { + await first.close(); // Wi-Fi drops + } + const again = await openAudienceStream(service, { activity: true }); + try { + await again.until(() => again.frames.length >= 1, "the reconnect snapshot"); + const session = again.frames[0]!.payload.session; + assert.equal(again.frames[0]!.type, "snapshot"); + assert.equal(session.activity.phase, "searching"); + assert.deepEqual(session.queued, [{ submissionId: "18", mode: "steer", text: "Keep it vegetarian." }]); + } finally { + await again.close(); + await service.close(); + } +}); + +test("an app step shows the app's name cut to 40 code points; other phases never carry one", () => { + const emoji = "😀".repeat(41); + assert.deepEqual( + projectBotSessionActivity({ phase: "app", app: emoji, startedAt: 1_760_450_000_000.7 }), + { phase: "app", app: "😀".repeat(40), startedAt: 1_760_450_000_000 }, + ); + assert.deepEqual(projectBotSessionActivity({ phase: "app", app: "Notion", startedAt: 5 }), { phase: "app", app: "Notion", startedAt: 5 }); + // No name to show: the phone reads its generic app word. + assert.deepEqual(projectBotSessionActivity({ phase: "app", app: " ", startedAt: 5 }), { phase: "app", startedAt: 5 }); + assert.deepEqual(projectBotSessionActivity({ phase: "reading", app: "Notion", startedAt: 5 }), { phase: "reading", startedAt: 5 }); + assert.deepEqual(projectBotSessionActivity({ phase: "thinking", startedAt: -3 }), { phase: "thinking", startedAt: 0 }); + // Every cut is one the strict parser accepts. + const cut = projectBotSessionActivity({ phase: "app", app: `${"x".repeat(39)}😀😀`, startedAt: 5 }); + assert.equal([...cut.app!].length, 40); + assert.deepEqual(parseAidenRemoteBotSessionEvent({ + protocolVersion: 1, botId: BOT_ID, epoch: "epoch_1", seq: 1, type: "activity", payload: { activity: cut }, + }).payload, { activity: cut }); +}); + +test("a frame that leaves the phase as it was sends no activity frame", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const service = runningOn(driven, fake); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the snapshot"); + const searching = { run: { inputs: [1] }, tools: [{ callId: "call_1", name: "web_search", status: "running" }] }; + await driven.emitView([user(1, "Plan dinners")], { "pi.live": searching }); + // The tool's output grows; the line still reads Searching the web. + await driven.emitView([user(1, "Plan dinners")], { + "pi.live": { ...searching, tools: [{ ...searching.tools[0], output: "3 results" }] }, + }); + await driven.emitView([user(1, "Plan dinners"), assistant(2, "Found three.")], { "pi.live": searching }); + await stream.until(() => stream.frames.some((frame) => frame.type === "entry"), "the reply entry"); + assert.deepEqual(stream.frames.slice(1).map((frame) => frame.type), ["activity", "entry"]); + } finally { + await stream.close(); + await service.close(); + } +}); + +test("a Stop pressed anywhere reads Stopping on phones until the run ends, and a Stop with nothing running changes nothing", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const intents = createBotTurnIntents(); + const service = runningOn(driven, fake, { turnIntents: intents }); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the snapshot"); + // The run is live in `pi.live` (slice B reads Stop only while a run exists). + await driven.emitView([user(1, "Plan dinners")], { "pi.live": { run: { inputs: [1] } } }); + intents.announce(BOT_ID, "stopping"); // the Mac's Stop + await stream.until(() => last(phasesOf(stream.frames)) === "stopping", "Stopping"); + intents.announce(BOT_ID, "stopCancelled"); // that Stop failed: the reply goes on + await stream.until(() => last(phasesOf(stream.frames)) === "preparing", "back to the work"); + intents.announce(BOT_ID, "stopping"); + await stream.until(() => last(phasesOf(stream.frames)) === "stopping", "Stopping again"); + + fake.setState({ kind: "idle" }); + await driven.emitView([user(1, "Plan dinners"), assistant(2, "Here is a start.")], {}); + await stream.until(() => last(phasesOf(stream.frames)) === null, "the run to end"); + + const settled = stream.frames.length; + const settledSeq = last(stream.frames)!.seq; + intents.announce(BOT_ID, "stopping"); // a double-tap after the reply landed + const fetched = await service.session(BOT_ID, { activity: true }); // runs after the intent on the projector's lane + assert.equal(fetched.seq, settledSeq, "nothing was running, so the intent published nothing"); + assert.equal(fetched.activity, null); + + fake.setState({ kind: "running", submissionId: "sub_2" }); + await driven.emitView( + [user(1, "Plan dinners"), assistant(2, "Here is a start."), user(3, "And lunches")], + { "pi.live": { run: { inputs: [3] } } }, + ); + await stream.until(() => last(phasesOf(stream.frames)) !== null, "the next run"); + // The lane is serial: anything the intent had published would have come before the next run's frames. + assert.equal(stream.frames[settled]!.seq, settledSeq + 1); + assert.deepEqual(phasesOf(stream.frames.slice(settled)), ["preparing"], "the next turn starts at Getting ready"); + assert.ok(gapless(stream.frames)); + } finally { + await stream.close(); + await service.close(); + } +}); + +test("a follow-up that replaces the run while the Bot stays running gets its own start time and drops Stopping", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const intents = createBotTurnIntents(); + let clock = 1_000; + const service = runningOn(driven, fake, { turnIntents: intents, now: () => clock }); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the snapshot"); + await driven.emitView([user(1, "Plan dinners")], { + "pi.live": { run: { inputs: [1] }, generation: { message: { content: [{ type: "text", text: "Mon" }] } } }, + }); + intents.announce(BOT_ID, "stopping"); + await stream.until(() => last(phasesOf(stream.frames)) === "stopping", "Stopping"); + + clock = 5_000; + // The queued follow-up's run replaces the stopped one; the session never left `running`. + await driven.emitView([user(1, "Plan dinners"), assistant(2, "Mon"), user(3, "And lunches")], { + "pi.live": { run: { inputs: [3] } }, + }); + await stream.until(() => last(activitiesOf(stream.frames))?.startedAt === 5_000, "the follow-up's run"); + assert.deepEqual(last(activitiesOf(stream.frames)), { phase: "preparing", startedAt: 5_000 }); + assert.equal(activitiesOf(stream.frames)[0]!.startedAt, 1_000); + } finally { + await stream.close(); + await service.close(); + } +}); + +test("a phone or desktop chat that opens mid-run, or after the projector's idle close, sees when the run really started", async () => { + const driven = drivenConversation([user(1, "Plan dinners")], { "pi.live": { run: { inputs: [1] } } }); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + let clock = 1_000; + const runStarts = createBotRunStarts(); + const service = runningOn(driven, fake, { now: () => clock, runStarts, projectorIdleMs: 20 }); + const desktop = createBotLiveProjection({ + conversation: async () => driven.conversation as never, + state: () => fake.runtime.state(BOT_ID), + quietInputs: async () => new Map(), + now: () => clock, + runStarts, + }); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the snapshot"); + const first = stream.frames[0]!.payload.session; + assert.deepEqual(first.activity, { phase: "preparing", startedAt: 1_000 }); + + clock = 31_000; // the Mac's chat opens half a minute in + const { snapshot, unsubscribe } = await desktop.subscribe(BOT_ID, { send() {} }); + assert.equal(snapshot.activity?.startedAt, 1_000, "the desktop line counts from the run's start"); + unsubscribe(); + + await stream.close(); + clock = 91_000; // the phone comes back after the projector closed for being idle + const deadline = Date.now() + 10_000; + let reopened = await service.session(BOT_ID, { activity: true }); + while (reopened.epoch === first.epoch) { + if (Date.now() > deadline) throw new Error("Timed out waiting for the idle close"); + await new Promise((resolve) => setTimeout(resolve, 10)); + reopened = await service.session(BOT_ID, { activity: true }); + } + assert.deepEqual(reopened.activity, { phase: "preparing", startedAt: 1_000 }, "a new projector, the same run"); + + // The run ends; the next one, even for the same input, is timed from its own start. + fake.setState({ kind: "idle" }); + assert.equal((await service.session(BOT_ID, { activity: true })).activity, null); + fake.setState({ kind: "running", submissionId: "sub_1" }); + clock = 120_000; + assert.deepEqual((await service.session(BOT_ID, { activity: true })).activity, { phase: "preparing", startedAt: 120_000 }); + } finally { + await stream.close(); + await desktop.close(); + await service.close(); + } +}); + +test("a Resume after a crash mid-reply reads Picking up where it left off on phones until the resumed run moves", async () => { + const stale = { run: { taskId: 3, inputs: [1] }, generation: { attempt: 1, message: { content: [{ type: "text", text: "Monday: soup" }] } } }; + const driven = drivenConversation([user(1, "Plan dinners")], { "pi.live": stale }); + const fake = fakeRuntime({ kind: "interrupted", submissionId: "sub_1" }); + const intents = createBotTurnIntents(); + const service = runningOn(driven, fake, { turnIntents: intents }); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the snapshot"); + assert.equal(stream.frames[0]!.payload.session.activity, null, "a paused Bot is not working"); + assert.equal(stream.frames[0]!.payload.session.partial, "Monday: soup"); + + // The session resumed and announced it (`onResumed`), before any event from the resumed run. + fake.setState({ kind: "running", submissionId: "sub_1" }); + intents.announce(BOT_ID, "resuming"); + await stream.until(() => last(phasesOf(stream.frames)) === "resuming", "Picking up where it left off"); + + await driven.emitView([user(1, "Plan dinners")], { "pi.live": { run: { taskId: 4, inputs: [1] }, generation: { attempt: 1 } } }); + await driven.emitView([user(1, "Plan dinners")], { + "pi.live": { run: { taskId: 4, inputs: [1] }, generation: { attempt: 1, message: { content: [{ type: "text", text: "Monday" }] } } }, + }); + await stream.until(() => last(phasesOf(stream.frames)) === "writing", "the resumed reply"); + assert.deepEqual(phasesOf(stream.frames), [null, "resuming", "writing"]); + } finally { + await stream.close(); + await service.close(); + } +}); + +test("Steer and Queue reach the runtime as steer and followUp, and only the person's waiting messages are listed", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const sends: Array<{ text: string; whenBusy?: string }> = []; + const service = new AidenRemoteBotSessionService({ + bots: activityBots(), + runtime: async () => ({ + ...fake.runtime, + conversation: async () => driven.conversation as never, + async send(_botId: string, input: { text: string; requestId: string; whenBusy?: "steer" | "followUp" }) { + sends.push({ text: input.text, ...(input.whenBusy ? { whenBusy: input.whenBusy } : {}) }); + return { submissionId: String(17 + sends.length), deduped: false }; + }, + // 20 is a routine's input: the session knows it from its durable request id. + async quietInputs(_botId: string, ids: readonly string[]) { + return new Map(ids.map((id) => [id, id === "20"])); + }, + }), + turnIntents: createBotTurnIntents(), + }); + const stream = await openAudienceStream(service, { activity: true }); + try { + await service.send(DEVICE_ID, BOT_ID, "send-request-steer-0001", { text: "Keep it vegetarian.", whenBusy: "steer" }); + await service.send(DEVICE_ID, BOT_ID, "send-request-queue-0001", { text: "Also a shopping list.", whenBusy: "queue" }); + await service.send(DEVICE_ID, BOT_ID, "send-request-plain-0001", { text: "Thanks!" }); + assert.deepEqual(sends, [ + { text: "Keep it vegetarian.", whenBusy: "steer" }, + { text: "Also a shopping list.", whenBusy: "followUp" }, + { text: "Thanks!" }, + ]); + + await stream.until(() => stream.frames.length >= 1, "the snapshot"); + await driven.emitView([user(1, "Plan dinners")], { + "pi.live": { run: { inputs: [1] } }, + "pi.inbox": { + items: [ + { id: 18, mode: "steer", content: [{ type: "text", text: "Keep it vegetarian." }] }, + { id: 19, mode: "write", entry: { kind: BOT_NOTICE_ENTRY_KIND, data: { notice: "routine" } } }, + { id: 20, mode: "followUp", content: [{ type: "text", text: "Weekly reset: plan the week." }] }, + { id: 21, mode: "followUp", content: [{ type: "text", text: "Also a shopping list." }] }, + // A photo with no words has nothing to show in a text bubble. + { id: 22, mode: "followUp", content: [{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }] }, + ], + }, + }); + await stream.until(() => stream.frames.some((frame) => frame.type === "queued"), "the queued frame"); + const expected = [ + { submissionId: "18", mode: "steer", text: "Keep it vegetarian." }, + { submissionId: "21", mode: "queue", text: "Also a shopping list." }, + ]; + assert.deepEqual(stream.frames.find((frame) => frame.type === "queued")!.payload.queued, expected); + assert.deepEqual((await service.session(BOT_ID, { activity: true })).queued, expected, "the snapshot agrees"); + assert.equal(stream.raw.some((data) => data.includes("Weekly reset")), false, "a routine's input is not the person's message"); + } finally { + await stream.close(); + await service.close(); + } +}); + +/** A message of `codePoints` CJK code points (three UTF-8 bytes each), led by a label that tells it apart. */ +function cjkMessage(label: string, codePoints = 32_000): string { + return `${label} ${"一".repeat(codePoints - [...label].length - 1)}`; +} + +/** The person's waiting follow-ups as the Mac's `pi.inbox` holds them; each id is its submission id. */ +function waitingInbox(texts: readonly string[]): Record { + return { + "pi.inbox": { + items: texts.map((text, index) => ({ id: 30 + index, mode: "followUp", content: [{ type: "text", text }] })), + }, + }; +} + +/** Every SSE block a device was sent parses through the native frame reader and stays inside the transport limit. */ +function assertEveryFrameFits(blocks: readonly string[]) { + assert.ok(blocks.length > 0, "the device was sent frames"); + for (const block of blocks) { + assert.ok(Buffer.byteLength(block, "utf8") <= AIDEN_REMOTE_MAX_SSE_FRAME_BYTES, "a frame exceeds the transport limit"); + assert.equal(parseAidenSseFrames(block).length, 1); + } +} + +test("twenty maximum-length CJK queued messages keep every frame and snapshot inside the transport's frame limit", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const service = runningOn(driven, fake); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the first snapshot"); + await driven.emitView( + [user(1, "Plan dinners")], + waitingInbox(Array.from({ length: 20 }, (_, index) => cjkMessage(`m${index}`))), + ); + await stream.until(() => stream.frames.some((frame) => frame.type === "queued"), "the queued frame"); + assertEveryFrameFits(stream.blocks); + + const session = await service.session(BOT_ID, { activity: true }); + assert.ok(Buffer.byteLength(JSON.stringify(session), "utf8") <= AIDEN_REMOTE_MAX_SSE_FRAME_BYTES, "the snapshot fits"); + const queuedFrames = stream.frames.filter((frame) => frame.type === "queued"); + const shown = queuedFrames[queuedFrames.length - 1]!.payload.queued; + assert.ok(shown.length >= 1 && shown.length < 20, "some of the twenty fit, and the rest are left out"); + assert.deepEqual(session.queued, shown, "the snapshot agrees with the queued frame"); + } finally { + await stream.close(); + await service.close(); + } +}); + +test("the queued frame keeps the oldest messages that fit, in the order sent, and each kept text is whole", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const service = runningOn(driven, fake); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the first snapshot"); + const texts = [...Array.from({ length: 19 }, (_, index) => cjkMessage(`m${index}`)), "Last, a short one."]; + await driven.emitView([user(1, "Plan dinners")], waitingInbox(texts)); + await stream.until(() => stream.frames.some((frame) => frame.type === "queued"), "the queued frame"); + + const queuedFrames = stream.frames.filter((frame) => frame.type === "queued"); + const queued = queuedFrames[queuedFrames.length - 1]!.payload.queued as Array<{ submissionId: string; text: string }>; + assert.ok(queued.length >= 1 && queued.length < texts.length, "some of the messages fit, and the rest are left out"); + const sentIds = texts.map((_, index) => String(30 + index)); + assert.deepEqual(queued.map((item) => item.submissionId), sentIds.slice(0, queued.length), "the oldest that fit, in order"); + assert.deepEqual(queued.map((item) => item.text), texts.slice(0, queued.length), "no kept text is cut"); + } finally { + await stream.close(); + await service.close(); + } +}); + +test("a transcript near its own budget with a long partial and a full queue still fits the frame limit, and the transcript gives way", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const service = runningOn(driven, fake); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the first snapshot"); + // Four replies of 64,000 code points (about 768 KB) fill the transcript's own cap on their own. + const transcript = [ + user(1, "Plan dinners"), + assistant(2, cjkMessage("a", 64_000)), + assistant(3, cjkMessage("b", 64_000)), + assistant(4, cjkMessage("c", 64_000)), + assistant(5, cjkMessage("d", 64_000)), + ]; + await driven.emitView(transcript, { + "pi.live": { generation: { message: { content: [{ type: "text", text: cjkMessage("p", 32_000) }] } } }, + ...waitingInbox(Array.from({ length: 20 }, (_, index) => cjkMessage(`m${index}`))), + }); + await stream.until(() => stream.frames.some((frame) => frame.type === "queued"), "the queued frame"); + assertEveryFrameFits(stream.blocks); + + const session = await service.session(BOT_ID, { activity: true }); + assert.ok(Buffer.byteLength(JSON.stringify(session), "utf8") <= AIDEN_REMOTE_MAX_SSE_FRAME_BYTES, "the snapshot fits"); + assert.ok(session.partial, "the partial is in the snapshot"); + assert.ok((session.queued?.length ?? 0) >= 1, "the queued list is in the snapshot"); + assert.equal(session.hasOlder, true, "the transcript gave way to the partial and the queue"); + assert.equal(session.entries[session.entries.length - 1]!.id, "entry_5", "the newest replies stay"); + } finally { + await stream.close(); + await service.close(); + } +}); + +/** U+0001: a control character, so each code point is written as a six-byte \u escape (the JSON worst case). */ +const ESCAPED = "\u0001"; + +/** A message of `codePoints` escaped code points, led by a label that tells it apart. */ +function escapedMessage(label: string, codePoints: number): string { + return `${label} ${ESCAPED.repeat(codePoints - [...label].length - 1)}`; +} + +/** Four questions at the protocol maxima, every character a six-byte escape. */ +function maximalQuestions(): AskUserQuestionV1[] { + return Array.from({ length: 4 }, (_, question) => ({ + question: `${question} ${ESCAPED.repeat(998)}`, // 1,000 code points + header: `h${question}${ESCAPED.repeat(14)}`, // 16 + multiSelect: false, + options: Array.from({ length: 5 }, (_, option) => ({ + label: `o${option} ${ESCAPED.repeat(57)}`, // 60 + description: `d${option} ${ESCAPED.repeat(1_997)}`, // 2,000 + })), + })); +} + +test("the parts that ship whole at the protocol maxima take well under half the frame limit", () => { + // Worst case in JSON bytes, six per code point: four questions of + // (1,000 + 16 + 5 x (60 + 2,000)) = 44,304 code points, 265,824 bytes; an + // approval of (2,000 + 120 + 128) = 2,248 code points, 13,488 bytes. State + // and activity add a few hundred bytes, so the group is under 300 KB, under + // the 512 KiB half of the 1 MiB frame limit. The partial (600,000 bytes) and + // the queued list (at most 256 KiB) share what is left of the snapshot budget. + const question = parseAidenRemoteBotQuestion({ waitId: WAIT_ID, toolCallId: ESCAPED.repeat(128), questions: maximalQuestions() }); + const approval = parseAidenRemoteBotApproval({ + waitId: WAIT_ID, + toolCallId: ESCAPED.repeat(128), + toolName: ESCAPED.repeat(120), + summary: ESCAPED.repeat(2_000), + canAllow: true, + }); + const bytes = Buffer.byteLength(JSON.stringify({ question, approval }), "utf8"); + assert.ok(bytes < AIDEN_REMOTE_MAX_SSE_FRAME_BYTES / 2, `the question and approval take ${bytes} bytes`); +}); + +test("a partial over its share is cut from the front on a code-point boundary, keeping the newest words", () => { + // From the end, JSON content bytes: a 4-byte emoji, three bytes per CJK + // character, six per escape. 4 + 3 x 5 + 6 = 25 keeps one escape before the CJK run. + const text = `head${ESCAPED.repeat(10)}一一一一一😀`; + const tail = tailWithinJsonBytes(text, 25); + assert.equal(tail, `${ESCAPED}一一一一一😀`); + const contentBytes = (value: string) => Buffer.byteLength(JSON.stringify(value), "utf8") - 2; + assert.ok(contentBytes(tail) <= 25, "the tail fits its share"); + assert.ok(contentBytes(`${ESCAPED}${tail}`) > 25, "one more code point would not fit: the cut is as long as it can be"); + assert.equal(tailWithinJsonBytes("short", 100), "short", "text that fits is kept whole"); + assert.equal(tailWithinJsonBytes("a😀", 3), "", "an emoji is never split, even to its lone half"); +}); + +test("the probe at the protocol maxima keeps every frame and the snapshot inside the frame limit", async () => { + // A 100,000-code-point escaped partial, four maximal questions and three + // 14,000-code-point escaped queued messages (84,000 JSON bytes each), plus an + // escaped 100,000-code-point reply. Written out, the old snapshot was about 1.12 MB. + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const questions = createBotQuestions(); + const service = runningOn(driven, fake, { questions, notifyBotsChanged: () => {} }); + const stream = await openAudienceStream(service, { activity: true }); + const asking = new AbortController(); + try { + await stream.until(() => stream.frames.length >= 1, "the first snapshot"); + await driven.emitView( + [user(1, "Plan dinners"), assistant(2, ESCAPED.repeat(100_000))], + { + "pi.live": { generation: { message: { content: [{ type: "text", text: ESCAPED.repeat(100_000) }] } } }, + ...waitingInbox(Array.from({ length: 3 }, (_, index) => escapedMessage(`m${index}`, 14_000))), + }, + ); + const asked = questions.request({ + botId: BOT_ID, + waitId: WAIT_ID, + toolCallId: "call_1", + questions: maximalQuestions(), + signal: asking.signal, + }); + asked.catch(() => undefined); + const kinds = ["entry", "partial", "question", "queued"]; + await stream.until( + () => kinds.every((kind) => stream.frames.some((frame) => frame.type === kind)), + "the entry, partial, question and queued frames", + ); + // Each frame on its own is inside the limit, and each parses through the native reader. + assertEveryFrameFits(stream.blocks); + + const session = await service.session(BOT_ID, { activity: true }); + const wire = JSON.stringify(session); + assert.ok(Buffer.byteLength(wire, "utf8") < AIDEN_REMOTE_MAX_SSE_FRAME_BYTES, "the snapshot is inside the frame limit"); + assert.deepEqual(parseAidenRemoteBotSession(JSON.parse(wire)), session, "the snapshot parses back to itself"); + // Priority order: the question (about 268 KB) and the whole partial (600,000 bytes) ship. + // What is left, about 115 KB of 983,040, takes one 84,000-byte queued message: the second + // would bring the list to 168,000 bytes. The transcript gets nothing and says so. + assert.deepEqual(session.question?.questions, maximalQuestions(), "the question ships whole"); + assert.equal(session.partial, ESCAPED.repeat(100_000), "the partial ships whole"); + assert.deepEqual(session.queued?.map((item) => [item.submissionId, item.text]), [ + ["30", escapedMessage("m0", 14_000)], + ]); + assert.deepEqual(session.entries, [], "the reply that no longer fits is left out"); + assert.equal(session.hasOlder, true); + } finally { + asking.abort(); + await stream.close(); + await service.close(); + } +}); + +test("a waiting input that can't be classified stays off phones and is retried, not asked again on every frame", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const asked: string[][] = []; + let failing = true; + const service = new AidenRemoteBotSessionService({ + bots: activityBots(), + runtime: async () => ({ + ...fake.runtime, + conversation: async () => driven.conversation as never, + async quietInputs(_botId: string, ids: readonly string[]) { + asked.push([...ids]); + if (failing) throw new Error("The session could not be inspected."); + return new Map(ids.map((id) => [id, id === "20"])); + }, + }), + turnIntents: createBotTurnIntents(), + classifyRetryMs: 300, + }); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the snapshot"); + const docs = { + "pi.live": { run: { inputs: [1] } }, + "pi.inbox": { + items: [ + { id: 20, mode: "followUp", content: [{ type: "text", text: "Weekly reset: plan the week." }] }, + { id: 21, mode: "followUp", content: [{ type: "text", text: "Also a shopping list." }] }, + ], + }, + }; + await driven.emitView([user(1, "Plan dinners")], docs); + await driven.emitView([user(1, "Plan dinners")], docs); + assert.deepEqual((await service.session(BOT_ID, { activity: true })).queued, [], "withheld while it can't be classified"); + assert.equal(asked.length, 1, "a failing classification is throttled, not retried on every frame"); + + failing = false; + await stream.until(() => stream.frames.some((frame) => frame.type === "queued"), "the retried classification"); + const queued = stream.frames.filter((frame) => frame.type === "queued").map((frame) => frame.payload.queued); + assert.deepEqual(queued, [[{ submissionId: "21", mode: "queue", text: "Also a shopping list." }]]); + assert.equal(asked.length, 2); + assert.equal(stream.raw.some((data) => data.includes("Weekly reset")), false); + } finally { + await stream.close(); + await service.close(); + } +}); + +test("the classification pause runs on the host's clock: once it has passed, the next frame asks again", async () => { + const driven = drivenConversation([user(1, "Plan dinners")]); + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const asked: string[][] = []; + let failing = true; + let clock = 1_000; + const service = runningOn(driven, fake, { + runtime: async () => ({ + ...fake.runtime, + conversation: async () => driven.conversation as never, + async quietInputs(_botId: string, ids: readonly string[]) { + asked.push([...ids]); + if (failing) throw new Error("The session could not be inspected."); + return new Map(ids.map((id) => [id, false])); + }, + }), + now: () => clock, + // Longer than the test: only a frame after the pause can ask again. + classifyRetryMs: 60_000, + }); + try { + await service.session(BOT_ID, { activity: true }); + const docs = { + "pi.live": { run: { inputs: [1] } }, + "pi.inbox": { items: [{ id: 21, mode: "followUp", content: [{ type: "text", text: "Also a shopping list." }] }] }, + }; + await driven.emitView([user(1, "Plan dinners")], docs); + await driven.emitView([user(1, "Plan dinners")], docs); + assert.equal(asked.length, 1, "within the pause"); + + failing = false; + clock += 60_000; + await driven.emitView([user(1, "Plan dinners")], docs); + assert.equal(asked.length, 2, "the pause is over on the host's clock"); + assert.deepEqual((await service.session(BOT_ID, { activity: true })).queued, [ + { submissionId: "21", mode: "queue", text: "Also a shopping list." }, + ]); + } finally { + await service.close(); + } +}); + +test("a host without bot-activity-v1 refuses whenBusy instead of guessing", async () => { + const fake = fakeRuntime({ kind: "running", submissionId: "sub_1" }); + const service = sessionService(fake.runtime); + assert.equal(service.supportsActivity, false); + await assert.rejects( + service.send(DEVICE_ID, BOT_ID, "send-request-steer-0002", { text: "Hi", whenBusy: "steer" }), + isError("invalid_request", 400), + ); + // Turn intents alone are not the token: the Bot service's Chat activity gate is needed too. + const ungated = new AidenRemoteBotSessionService({ bots: botService(), runtime: async () => fake.runtime, turnIntents: createBotTurnIntents() }); + assert.equal(ungated.supportsActivity, false); + await assert.rejects( + ungated.send(DEVICE_ID, BOT_ID, "send-request-queue-0002", { text: "Hi", whenBusy: "queue" }), + isError("invalid_request", 400), + ); + await service.close(); + await ungated.close(); +}); + +// --------------------------------------------------------------------------- +// Contract revision 30: the person's messages carry their submissionId +// --------------------------------------------------------------------------- + +/** Every `submissionId` key anywhere in a frame or snapshot. */ +function submissionIdKeys(value: unknown): number { + return JSON.stringify(value).match(/"submissionId"/gu)?.length ?? 0; +} + +/** The user bubbles a device holds, as `[text, submissionId]`. */ +function userBubbles(entries: readonly { type: string; role?: string; text?: string; submissionId?: string }[]) { + return entries.filter((entry) => entry.type === "message" && entry.role === "user").map((entry) => [entry.text, entry.submissionId ?? null]); +} + +test("a person's send carries its submissionId on its bubble in frames and on a reconnect snapshot, and a routine's input never does", async () => { + const profileDir = mkdtempSync(path.join(os.tmpdir(), "aiden-remote-bot-submission-")); + durableRoots.push(profileDir); + const releases: Array<() => void> = []; + const command = countingTool("run_command", () => new Promise((resolve) => releases.push(() => resolve("done")))); + const fauxModels = createFauxModels([ + fauxAssistantMessage([fauxToolCall("run_command", { text: "tidy" })], { stopReason: "toolUse" }), + fauxAssistantMessage("Tidied."), + fauxAssistantMessage([fauxToolCall("run_command", { text: "brief" })], { stopReason: "toolUse" }), + fauxAssistantMessage("Inbox is quiet."), + ]); + const runtime = await createBotSessionService({ + profileDir, + models: fauxModels.models, + extension: recordingDeps({ tools: [{ tool: command, replay: "unsafe" }] }), + resolveModel: async () => FAUX_MODEL_REF, + knownBotIds: async () => new Set([BOT_ID]), + }); + const service = new AidenRemoteBotSessionService({ bots: activityBots(), runtime: async () => runtime, turnIntents: createBotTurnIntents() }); + const holder = await openAudienceStream(service, { activity: true }); + const legacy = await openAudienceStream(service, {}); + const idle = async () => { + await waitFor(async () => (await runtime.state(BOT_ID)).kind === "idle", { what: "the turn to end" }); + }; + try { + await holder.until(() => holder.frames.length >= 1, "the first snapshot"); + const receipt = await service.send(DEVICE_ID, BOT_ID, "send-request-submission-0001", { text: "Tidy my notes" }); + await waitFor(() => command.executions.length === 1, { what: "the person's turn to work" }); + // While the turn works, the session lists the person's placed input under its entry. + assert.deepEqual([...(await runtime.placedSubmissions(BOT_ID)).values()], [receipt.submissionId]); + releases.shift()!(); + await idle(); + + // A routine's input, worked on while the projector watches. + await runtime.send(BOT_ID, { text: "Check my inbox.", requestId: "routine:task_1:1", label: "Morning brief" }); + await waitFor(() => command.executions.length === 2, { what: "the routine's turn to work" }); + assert.deepEqual([...(await runtime.placedSubmissions(BOT_ID)).values()], [], "a routine's input is not the person's message"); + releases.shift()!(); + await idle(); + await holder.until( + () => holder.frames.some((frame) => frame.type === "entry" && frame.payload.entry.text === "Inbox is quiet."), + "the routine's reply", + ); + + const sentFrame = holder.frames.find((frame) => frame.type === "entry" && frame.payload.entry.text === "Tidy my notes")!; + assert.equal(sentFrame.payload.entry.submissionId, receipt.submissionId, "the bubble's frame names the send it settles"); + const routineFrame = holder.frames.find((frame) => frame.type === "entry" && frame.payload.entry.text === "Check my inbox.")!; + assert.equal("submissionId" in routineFrame.payload.entry, false); + + // Both turns are done, so the session no longer lists them; the projector remembers. + assert.deepEqual([...(await runtime.placedSubmissions(BOT_ID)).values()], []); + const reconnect = await openAudienceStream(service, { activity: true }); + try { + await reconnect.until(() => reconnect.frames.length >= 1, "the reconnect snapshot"); + const expected = [["Tidy my notes", receipt.submissionId], ["Check my inbox.", null]]; + assert.deepEqual(userBubbles(reconnect.frames[0]!.payload.session.entries), expected); + assert.deepEqual(userBubbles((await service.session(BOT_ID, { activity: true })).entries), expected, "a fetch agrees"); + } finally { + await reconnect.close(); + } + + assert.equal(legacy.raw.some((data) => data.includes("submissionId")), false, "a device without bot:activity never sees the key"); + assert.equal(submissionIdKeys(await service.session(BOT_ID)), 0); + assert.ok(gapless(holder.frames) && gapless(legacy.frames)); + } finally { + for (const release of releases) release(); + await holder.close(); + await legacy.close(); + await service.close(); + await runtime.shutdown(); + } +}); + +test("only the person's own message is tagged: a routine's input and a question answer never carry a submissionId", () => { + const entries = projectBotSessionEntries( + [ + user(1, "Plan dinners"), + notice(2, { notice: "routine", label: "Morning brief", requestId: "routine:t:1" }), + user(3, "Check my inbox."), + { + id: 4, + kind: "pi.tool-result", + model: [{ role: "toolResult", toolName: "ask_user_question", isError: false, content: [{ type: "text", text: "Answer: Blue" }], details: { answerText: "Blue" } }], + }, + assistant(5, "Done."), + user(6, "Thanks"), + ] as never, + // A runtime that wrongly listed every entry: only the person's own messages take an id. + new Map([["1", "17"], ["3", "18"], ["4", "19"], ["5", "20"], ["6", "bad id"]]), + ); + assert.deepEqual( + entries.map((entry) => (entry.type === "message" ? [entry.role, entry.text, entry.submissionId ?? null] : [entry.type])), + [ + ["user", "Plan dinners", "17"], + ["user", "Check my inbox.", null], + ["user", "Blue", null], + ["assistant", "Done.", null], + ["user", "Thanks", null], + ], + ); +}); + +test("a device without bot:activity gets each tagged bubble without its id, at the same seq", async () => { + const driven = drivenConversation([]); + const fake = fakeRuntime({ kind: "running", submissionId: "17" }); + let placed = new Map(); + const service = runningOn(driven, { ...fake, runtime: { ...fake.runtime, placedSubmissions: async () => placed } }); + const holder = await openAudienceStream(service, { activity: true }); + const legacy = await openAudienceStream(service, {}); + try { + await holder.until(() => holder.frames.length >= 1, "the holder snapshot"); + await legacy.until(() => legacy.frames.length >= 1, "the legacy snapshot"); + placed = new Map([["1", "17"]]); + await driven.emit([user(1, "Plan dinners")]); + // The turn ended: the session forgets the submission, the projector does not. + placed = new Map(); + fake.setState({ kind: "idle" }); + await driven.emit([user(1, "Plan dinners"), assistant(2, "Monday: soup.")]); + await holder.until(() => holder.frames.some((frame) => frame.type === "entry" && frame.payload.entry.role === "assistant"), "the reply"); + await legacy.until(() => legacy.frames.length === holder.frames.length, "the same frames"); + + const bubbles = (frames: Frame[]) => frames.filter((frame) => frame.type === "entry" && frame.payload.entry.role === "user"); + assert.deepEqual(bubbles(holder.frames).map((frame) => frame.payload.entry.submissionId), ["17"], "one entry frame, never updated"); + const { submissionId: _submissionId, ...untagged } = bubbles(holder.frames)[0]!.payload.entry; + assert.deepEqual(bubbles(legacy.frames).map((frame) => frame.payload.entry), [untagged], "the same bubble, without its id"); + assert.deepEqual(legacy.frames.map((frame) => frame.seq), holder.frames.map((frame) => frame.seq), "every device stays on one seq"); + assert.equal(legacy.raw.some((data) => data.includes("submissionId")), false); + + assert.deepEqual(userBubbles((await service.session(BOT_ID, { activity: true })).entries), [["Plan dinners", "17"]]); + assert.equal(submissionIdKeys(await service.session(BOT_ID)), 0, "a fetch without bot:activity has no submissionId"); + } finally { + await holder.close(); + await legacy.close(); + await service.close(); + } +}); + +test("tags stay on every bubble in view, past the window, and no old bubble is ever sent again", async () => { + const driven = drivenConversation([]); + const fake = fakeRuntime({ kind: "running", submissionId: "1" }); + let asked = 0; + let current: RawEntry[] = []; + const service = runningOn(driven, { + ...fake, + runtime: { + ...fake.runtime, + async placedSubmissions() { + asked += 1; + // Only the message just sent is still placed; every earlier turn is done. + const newest = current[current.length - 1]; + return new Map(newest ? [[String(newest.id), `sub_${newest.id}`]] : []); + }, + }, + }); + const total = AIDEN_REMOTE_BOT_SESSION_MAX_ENTRIES + 10; + const holder = await openAudienceStream(service, { activity: true }); + const legacy = await openAudienceStream(service, {}); + try { + await holder.until(() => holder.frames.length >= 1, "the holder snapshot"); + await legacy.until(() => legacy.frames.length >= 1, "the legacy snapshot"); + for (let id = 1; id <= total; id += 1) { + current = [...current, user(id, `Message ${id}`)]; + await driven.emit(current); + if (id % 50 === 0) await driven.emit(current, "Working"); // streaming frames ask nothing + } + const entryFrames = (frames: Frame[]) => frames.filter((frame) => frame.type === "entry"); + await holder.until(() => entryFrames(holder.frames).length >= total, "every message"); + await legacy.until(() => legacy.frames.length === holder.frames.length, "the same frames"); + + for (const stream of [holder, legacy]) { + // Each bubble is sent exactly once, when it is new: never again when an older one leaves view. + assert.deepEqual( + entryFrames(stream.frames).map((frame) => frame.payload.entry.id), + Array.from({ length: total }, (_, index) => `entry_${index + 1}`), + ); + assert.ok(gapless(stream.frames)); + } + assert.deepEqual(legacy.frames.map((frame) => frame.seq), holder.frames.map((frame) => frame.seq)); + assert.deepEqual( + entryFrames(holder.frames).map((frame) => frame.payload.entry.submissionId), + Array.from({ length: total }, (_, index) => `sub_${index + 1}`), + ); + assert.equal(asked, total, "one lookup per new message"); + + // Long after their turns ended, every bubble in view still names its send. + const bubbles = userBubbles((await service.session(BOT_ID, { activity: true })).entries); + assert.equal(bubbles.length, AIDEN_REMOTE_BOT_SESSION_MAX_ENTRIES); + assert.deepEqual(bubbles.map(([, id]) => id), bubbles.map(([text]) => `sub_${String(text).slice("Message ".length)}`)); + assert.equal(legacy.raw.some((data) => data.includes("submissionId")), false); + // The memo forgot the bubbles that left view: it holds no more tags than the window shows. + assert.equal(await service.submissionTagsHeld(BOT_ID), AIDEN_REMOTE_BOT_SESSION_MAX_ENTRIES); + } finally { + await holder.close(); + await legacy.close(); + await service.close(); + } +}); + +test("a failed placement lookup is reported, and that bubble falls back to its text", async () => { + const driven = drivenConversation([]); + const fake = fakeRuntime({ kind: "running", submissionId: "1" }); + const reports: unknown[] = []; + let failing = true; + const service = runningOn( + driven, + { + ...fake, + runtime: { + ...fake.runtime, + async placedSubmissions() { + if (failing) throw new Error("The session could not be inspected."); + return new Map([["3", "sub_3"]]); + }, + }, + }, + { onReport: (_botId, error) => reports.push(error) }, + ); + try { + await service.session(BOT_ID, { activity: true }); + await driven.emit([user(1, "Plan dinners")]); + await driven.emit([user(1, "Plan dinners"), user(2, "And snacks")]); + assert.equal(reports.length, 1, "a failing streak is reported once"); + failing = false; + await driven.emit([user(1, "Plan dinners"), user(2, "And snacks"), user(3, "And lunches")]); + assert.deepEqual(userBubbles((await service.session(BOT_ID, { activity: true })).entries), [ + ["Plan dinners", null], + ["And snacks", null], + ["And lunches", "sub_3"], + ]); + failing = true; + await driven.emit([user(1, "Plan dinners"), user(2, "And snacks"), user(3, "And lunches"), user(4, "And breakfasts")]); + assert.deepEqual(reports.map((error) => (error as Error).message), [ + "The session could not be inspected.", + "The session could not be inspected.", + ], "a new streak after a success is reported again"); + } finally { + await service.close(); + } +}); + +/** Publish order: frames one publish emits never go back in this order. */ +const PUBLISH_ORDER = ["entry", "partial", "state", "question", "approval", "activity", "queued"]; + +test("a steer and a follow-up placed mid-turn take their queued ids, two at one boundary each their own, in the publish that drops them from Queued", async () => { + const profileDir = mkdtempSync(path.join(os.tmpdir(), "aiden-remote-bot-placed-")); + durableRoots.push(profileDir); + const releases: Array<() => void> = []; + const command = countingTool("run_command", () => new Promise((resolve) => releases.push(() => resolve("done")))); + const fauxModels = createFauxModels([ + fauxAssistantMessage([fauxToolCall("run_command", { text: "plan" })], { stopReason: "toolUse" }), + fauxAssistantMessage("Vegetarian, no mushrooms."), + fauxAssistantMessage("Here is the shopping list."), + ]); + const runtime = await createBotSessionService({ + profileDir, + models: fauxModels.models, + extension: recordingDeps({ tools: [{ tool: command, replay: "unsafe" }] }), + resolveModel: async () => FAUX_MODEL_REF, + knownBotIds: async () => new Set([BOT_ID]), + }); + const service = new AidenRemoteBotSessionService({ bots: activityBots(), runtime: async () => runtime, turnIntents: createBotTurnIntents() }); + const stream = await openAudienceStream(service, { activity: true }); + try { + await stream.until(() => stream.frames.length >= 1, "the first snapshot"); + const first = await service.send(DEVICE_ID, BOT_ID, "send-request-placed-0001", { text: "Plan dinners" }); + await waitFor(() => command.executions.length === 1, { what: "the turn to work" }); + const followUp = await service.send(DEVICE_ID, BOT_ID, "send-request-placed-0002", { text: "Also a shopping list.", whenBusy: "queue" }); + const steer = await service.send(DEVICE_ID, BOT_ID, "send-request-placed-0003", { text: "Keep it vegetarian.", whenBusy: "steer" }); + const second = await service.send(DEVICE_ID, BOT_ID, "send-request-placed-0004", { text: "No mushrooms.", whenBusy: "steer" }); + const ids = [followUp, steer, second].map((receipt) => receipt.submissionId); + await stream.until( + () => stream.frames.some((frame) => frame.type === "queued" && frame.payload.queued.length === 3), + "all three waiting", + ); + releases.shift()!(); + await stream.until( + () => stream.frames.some((frame) => frame.type === "entry" && frame.payload.entry.text === "Here is the shopping list."), + "the follow-up's reply", + ); + + const bubbleFrame = (text: string) => + stream.frames.findIndex((frame) => frame.type === "entry" && frame.payload.entry.role === "user" && frame.payload.entry.text === text); + const placedIds = ["Plan dinners", "Also a shopping list.", "Keep it vegetarian.", "No mushrooms."].map( + (text) => stream.frames[bubbleFrame(text)]!.payload.entry.submissionId, + ); + assert.deepEqual(placedIds, [first.submissionId, ...ids], "each bubble names its own send"); + assert.equal(new Set(placedIds).size, 4); + + for (const [text, id] of [["Also a shopping list.", ids[0]], ["Keep it vegetarian.", ids[1]], ["No mushrooms.", ids[2]]] as const) { + const at = bubbleFrame(text); + const listing = stream.frames.map((frame) => + frame.type === "queued" && frame.payload.queued.some((item: { submissionId: string }) => item.submissionId === id)); + const lastListed = listing.lastIndexOf(true); + const dropped = stream.frames.findIndex((frame, index) => index > lastListed && frame.type === "queued"); + assert.ok(lastListed < at && at < dropped, `${text}: listed, then placed, then dropped`); + // From the bubble to the Queued change, the frames follow one publish's order. + const order = stream.frames.slice(at, dropped + 1).map((frame) => PUBLISH_ORDER.indexOf(frame.type)); + assert.deepEqual(order, [...order].sort((left, right) => left - right), `${text}: dropped in the publish that placed it`); + } + // Pi places waiting inputs at a boundary: at least two of them landed in one publish + // (consecutive bubble frames), and each still named its own send. + const together = stream.frames.findIndex((frame, index) => { + const next = stream.frames[index + 1]; + return frame.type === "entry" && frame.payload.entry.role === "user" + && next?.type === "entry" && next.payload.entry.role === "user"; + }); + assert.ok(together > 0, "two inputs placed at one boundary"); + const pair = [stream.frames[together]!, stream.frames[together + 1]!].map((frame) => frame.payload.entry.submissionId); + assert.ok(pair.every((id) => ids.includes(id)) && pair[0] !== pair[1], JSON.stringify(pair)); + assert.ok(gapless(stream.frames)); + } finally { + for (const release of releases) release(); + await stream.close(); + await service.close(); + await runtime.shutdown(); + } +}); diff --git a/main/services/aiden-remote-bot-session.ts b/main/services/aiden-remote-bot-session.ts index 674b817bd..f9c7da347 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -15,6 +15,14 @@ // `approval` frame, in the same order. Phones answer it by `waitId` through // the shared `POST /approvals/{id}/respond` route (`respondApproval`), // idempotent per request UUID; Computer Use can only be denied from a phone. +// - Contract revision 30 (`bot-activity-v1`): devices holding `bot:activity` +// also get `activity` (the §5 phase, cut to `{ phase, app?, startedAt }`) and +// `queued` (the person's waiting messages) in the snapshot and as frames. +// Other devices get the state they already hold at those seqs. A waiting +// input shows only once the session classified it as the person's own, so a +// routine's or the self-intro's prompt never reaches a phone. The person's +// own messages also carry the `submissionId` they were sent as, so a phone +// settles its optimistic bubble exactly; other devices never see the key. // - Routines, connection requests and starter presets call their main services. import { randomUUID } from "node:crypto"; @@ -39,7 +47,12 @@ import { } from "../../renderer/shared/bot-routine-proposals.js"; import type { BotMemoryService } from "./bot-memory/bot-memory-main.js"; import { + AIDEN_REMOTE_BOT_ACTIVITY_APP_MAX_CHARS, AIDEN_REMOTE_BOT_CARD_ENTRY_TYPES, + AIDEN_REMOTE_BOT_MESSAGE_MAX_CHARS, + AIDEN_REMOTE_BOT_QUEUED_MAX, + type AidenRemoteBotQueuedMessage, + type AidenRemoteBotSessionActivity, parseAidenRemoteBotMemory, parseAidenRemoteBotMemoryEditRequest, parseAidenRemoteBotRoutineProposalId, @@ -53,6 +66,7 @@ import { AIDEN_REMOTE_BOT_PRESETS_MAX, AIDEN_REMOTE_BOT_SESSION_MAX_ENTRIES, AIDEN_REMOTE_BOT_SESSION_MAX_TEXT_CHARS, + AIDEN_REMOTE_MAX_SSE_FRAME_BYTES, AIDEN_REMOTE_PROTOCOL_VERSION, parseAidenRemoteBotConnectionRequest, parseAidenRemoteBotMessageRequest, @@ -85,8 +99,21 @@ import { openCursorSse, sseFrame, type CursorSseHandle } from "./aiden-remote-ss import type { BotApprovals } from "./bot-runtime/bot-approvals.js"; import type { BotQuestions } from "./bot-runtime/bot-questions.js"; import { isComputerUseCapabilityTool } from "./bot-tool-authority.js"; -import { BotSessionError, type BotSessionState } from "./bot-runtime/bot-session-service.js"; -import { livePartialText, projectBotTranscript } from "./bot-runtime/live-projection.js"; +import { AidenOperationNotExecutedError } from "./aiden-remote-operation-contract.js"; +import { BotSessionError, type BotSessionErrorReason, type BotSessionState } from "./bot-runtime/bot-session-service.js"; +import type { BotLiveActivity } from "../../renderer/shared/bot-live.js"; +import { stopWithIntents, type BotTurnIntent, type BotTurnIntents } from "./bot-runtime/bot-turn-intents.js"; +import { + liveBotActivity, + livePartialText, + liveQueuedMessages, + liveRunKey, + createBotRunStarts, + createQueuedInputClassifier, + projectBotTranscript, + type BotRunStarts, + type QueuedInputClassifier, +} from "./bot-runtime/live-projection.js"; import { botRoutineLabel, botRoutinePromptOf, @@ -97,14 +124,33 @@ import { /** Kind of the typed connect card entry `suggest_connection` appends. */ export { BOT_CONNECT_CARD_ENTRY_KIND } from "./bot-runtime/live-projection.js"; const ctx = BACKGROUND_CONTEXT; +/** The transcript window's own cap, applied on top of what the snapshot budget leaves it. */ const MAX_SESSION_JSON_BYTES = 768 * 1_024; +/** + * Serialized bytes one session snapshot's `session` object may take: the + * transport's SSE frame limit less a margin for the SSE id and event lines and + * the frame envelope (`protocolVersion`, `botId`, `epoch`, `seq`, `type`, at most + * about 1 KB with a 160-scalar `botId`). The snapshot allocates this budget in + * priority order (see `snapshot`). + */ +const SNAPSHOT_BUDGET_BYTES = AIDEN_REMOTE_MAX_SSE_FRAME_BYTES - 64 * 1_024; +/** + * Serialized bytes the queued list may take. Oldest items first; the first one + * that would overflow ends the list, so the rest are left out of the projection + * (phones keep their own outbox copies). Text is never truncated. + */ +const MAX_QUEUED_JSON_BYTES = 256 * 1_024; const MAX_PENDING_FRAMES = 256; const PROJECTOR_IDLE_MS = 60_000; +/** Pause before a failed queued-input classification is tried again (the desktop's throttle). */ +const CLASSIFY_RETRY_MS = 1_000; +const WIRE_SUBMISSION_ID = /^[A-Za-z0-9._:-]{1,64}$/u; export interface AidenRemoteBotSessionRuntime { send( botId: string, - input: { text: string; requestId: string }, + /** `whenBusy` while a turn runs: `steer` joins it, `followUp` (the default) runs after it. */ + input: { text: string; requestId: string; whenBusy?: "steer" | "followUp" }, ): Promise<{ submissionId: string; deduped: boolean }>; resume(botId: string, requestId: string): Promise; dismiss(botId: string, requestId: string): Promise; @@ -112,11 +158,25 @@ export interface AidenRemoteBotSessionRuntime { stop(botId: string): Promise; state(botId: string): Promise; conversation(botId: string): Promise; + /** + * For each queued submission id it can find: `true` for a routine's input or + * the hidden self-intro, `false` for the person's own message. Ids it cannot + * find are left out (`BotSessionRuntime.quietInputs`). + */ + quietInputs(botId: string, submissionIds: readonly string[]): Promise>; + /** + * The person's own inputs placed in the running turn, by the `pi.user` + * entry each became (entry id → submission id); never a routine's input or + * the self-intro (`BotSessionRuntime.placedSubmissions`). + */ + placedSubmissions(botId: string): Promise>; } export interface AidenRemoteBotSessionServiceOptions { /** The live Bot lookup and the shared idempotency ledger (`AidenRemoteBotService`). */ bots: { + /** The Bot service projects and accepts Chat activity (`chatActivity: true`): half of `bot-activity-v1`. */ + readonly supportsChatActivity?: boolean; bot(botId: string): Promise; summaryOf(botId: string): Promise; executeIdempotent( @@ -130,6 +190,28 @@ export interface AidenRemoteBotSessionServiceOptions { questions?: Pick; /** The Bot's waiting tool approvals (the same bridge the desktop answers through). */ approvals?: Pick; + /** + * Stop and Resume from any surface (`botTurnIntents`). A phone's Stop is + * announced here, and taken back if it fails, so open desktop chats read + * "Stopping" too. Resume is announced by the session itself (`onResumed`). + * With the Bot service's Chat activity gate it is also revision 30: open + * streams hear every surface's Stop and Resume (`supportsActivity`). + */ + turnIntents?: Pick; + /** + * A queued input could not be classified (it stays off phones and is + * retried), or the submission a new message was placed as could not be read + * (that bubble goes without its id). Each is reported once per failing streak. + */ + onReport?(botId: string, error: unknown): void; + /** Pause before a failed classification is retried; defaults to one second. */ + classifyRetryMs?: number; + /** + * Run start times shared with the desktop live projection (`botRunStarts`): + * a projector created mid-run, or after the 60 s idle close, keeps the + * run's start instead of its own creation time. Defaults to this service's own. + */ + runStarts?: BotRunStarts; routines?: Pick; presets?: { list(): readonly BotPreset[]; @@ -208,8 +290,15 @@ const wireEntryId = (id: string) => `entry_${id}`; * turns are left out, tool traffic stays on the Mac, a failed turn is a typed * `failed_turn` entry (with the message Retry resends), * and each plugin has one connect card (latest status at the first card's place). + * + * `submissions` (revision 30) names the submission each of the person's own + * messages was sent as, by entry id. Only a `user` message takes one: a + * routine's input and a question answer never do, whatever the map says. */ -export function projectBotSessionEntries(entries: readonly EntryRecord[]): AidenRemoteBotSessionEntry[] { +export function projectBotSessionEntries( + entries: readonly EntryRecord[], + submissions: ReadonlyMap = new Map(), +): AidenRemoteBotSessionEntry[] { const output: AidenRemoteBotSessionEntry[] = []; const cards = new Map(); for (const entry of projectBotTranscript(entries)) { @@ -219,6 +308,7 @@ export function projectBotSessionEntries(entries: readonly EntryRecord[]): Aiden case "routine": { const createdAt = isoOf(entry.at); const label = entry.type === "routine" ? wireText(entry.label, 120).trim() : ""; + const submissionId = entry.type === "user" ? submissions.get(entry.id) : undefined; output.push({ type: "message", id, @@ -227,6 +317,7 @@ export function projectBotSessionEntries(entries: readonly EntryRecord[]): Aiden text: bounded(entry.type === "routine" ? botRoutinePromptOf(entry.text) : entry.text), ...(createdAt ? { createdAt } : {}), ...(label ? { label } : {}), + ...(submissionId !== undefined && WIRE_SUBMISSION_ID.test(submissionId) ? { submissionId } : {}), }); break; } @@ -328,12 +419,108 @@ export function withoutBotCards(entries: readonly AidenRemoteBotSessionEntry[]): ); } +/** The entry a device without `bot:activity` may see: shipped phones decode message keys strictly. */ +function withoutSubmissionId(entry: AidenRemoteBotSessionEntry): AidenRemoteBotSessionEntry { + if (entry.type !== "message" || entry.submissionId === undefined) return entry; + const { submissionId: _submissionId, ...rest } = entry; + return rest; +} + +/** The entries one device is sent: cards for `bot:cards`, submission ids for `bot:activity`. */ +function entriesFor(entries: readonly AidenRemoteBotSessionEntry[], audience: Audience): AidenRemoteBotSessionEntry[] { + const shown = audience.cards ? [...entries] : withoutBotCards(entries); + return audience.activity ? shown : shown.map(withoutSubmissionId); +} + /** Text of the in-flight (or paused) assistant partial, if any. A reply heading for `[SILENT]` is never shown. */ export function projectBotSessionPartial(view: Pick): string | undefined { const text = livePartialText(view.docs); return text ? bounded(text) : undefined; } +/** The §11 allow-list, picked by name: nothing from the step crosses the wire. */ +export function projectBotSessionActivity( + live: Pick, +): AidenRemoteBotSessionActivity { + const app = live.phase === "app" && live.app + ? wireText(live.app, AIDEN_REMOTE_BOT_ACTIVITY_APP_MAX_CHARS).trim() + : ""; + return { + phase: live.phase, + ...(app ? { app } : {}), + startedAt: Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, Math.floor(live.startedAt))), + }; +} + +/** + * The person's waiting messages, oldest first: only inputs in `personal` + * (classified as the person's own) show, so an input not yet classified stays + * off the wire. A photo with no words has no text to show and is left out. + * The list keeps the oldest items whose serialized bytes fit + * `MAX_QUEUED_JSON_BYTES`, in order, and omits the rest. + */ +export function projectBotSessionQueued( + docs: Readonly>, + personal: ReadonlySet, +): AidenRemoteBotQueuedMessage[] { + const seen = new Set(); + const valid: AidenRemoteBotQueuedMessage[] = []; + for (const message of liveQueuedMessages(docs, personal)) { + const text = wireText(message.text, AIDEN_REMOTE_BOT_MESSAGE_MAX_CHARS); + if (!text.trim() || !WIRE_SUBMISSION_ID.test(message.submissionId) || seen.has(message.submissionId)) continue; + seen.add(message.submissionId); + valid.push({ submissionId: message.submissionId, mode: message.mode === "steer" ? "steer" : "queue", text }); + if (valid.length === AIDEN_REMOTE_BOT_QUEUED_MAX) break; + } + return oldestThatFit(valid, MAX_QUEUED_JSON_BYTES); +} + +/** + * The oldest items whose serialized JSON array fits `budgetBytes`, in order. The + * first item that would overflow ends the list, so the rest are omitted; an item + * is never cut. + */ +function oldestThatFit(items: readonly Item[], budgetBytes: number): Item[] { + const kept: Item[] = []; + let bytes = 2; // the brackets + for (const item of items) { + const size = Buffer.byteLength(JSON.stringify(item), "utf8") + (kept.length > 0 ? 1 : 0); + if (bytes + size > budgetBytes) break; + kept.push(item); + bytes += size; + } + return kept; +} + +/** The serialized bytes of a JSON string's content: its quotes excluded, its escapes counted as written. */ +function jsonContentBytes(text: string): number { + return Buffer.byteLength(JSON.stringify(text), "utf8") - 2; +} + +/** + * The longest tail of `text`, cut on a code-point boundary, whose JSON string + * content fits `budgetBytes`. Text that fits is returned whole; otherwise its + * front is cut, so a phone keeps the newest streamed words. + */ +export function tailWithinJsonBytes(text: string, budgetBytes: number): string { + if (jsonContentBytes(text) <= budgetBytes) return text; + const points = [...text]; + let bytes = 0; + let start = points.length; + while (start > 0) { + const size = jsonContentBytes(points[start - 1]!); + if (bytes + size > budgetBytes) break; + bytes += size; + start -= 1; + } + return points.slice(start).join(""); +} + +/** The serialized bytes one `"key":value` member adds to a JSON object, its separating comma included. */ +function memberBytes(key: string, value: unknown): number { + return Buffer.byteLength(JSON.stringify({ [key]: value }), "utf8") - 1; +} + export function projectBotSessionState(state: BotSessionState): AidenRemoteBotSessionStateView { if (state.kind === "interrupted") { return { state: "interrupted", interrupted: true, ...(state.blocked ? { blocked: state.blocked } : {}) }; @@ -344,11 +531,21 @@ export function projectBotSessionState(state: BotSessionState): AidenRemoteBotSe return { state: state.kind as AidenRemoteBotSessionStateKind, interrupted: false }; } -function windowed(entries: AidenRemoteBotSessionEntry[]): { entries: AidenRemoteBotSessionEntry[]; hasOlder: boolean } { +/** + * The newest entries that fit the transcript's share of the snapshot: at most + * `budgetBytes` of serialized entries and `MAX_SESSION_JSON_BYTES`. An entry + * that cannot fit is left out (`hasOlder`), never cut, so the snapshot stays + * inside the frame limit whatever else it carries. + */ +function windowed( + entries: AidenRemoteBotSessionEntry[], + budgetBytes: number, +): { entries: AidenRemoteBotSessionEntry[]; hasOlder: boolean } { + const limit = Math.min(MAX_SESSION_JSON_BYTES, budgetBytes); let start = Math.max(0, entries.length - AIDEN_REMOTE_BOT_SESSION_MAX_ENTRIES); let window = entries.slice(start); - while (window.length > 1 && Buffer.byteLength(JSON.stringify(window), "utf8") > MAX_SESSION_JSON_BYTES) { - start += Math.max(1, Math.floor(window.length / 4)); + while (window.length > 0 && Buffer.byteLength(JSON.stringify(window), "utf8") > limit) { + start = Math.min(entries.length, start + Math.max(1, Math.floor(window.length / 4))); window = entries.slice(start); } return { entries: window, hasOlder: start > 0 }; @@ -358,12 +555,34 @@ function windowed(entries: AidenRemoteBotSessionEntry[]): { entries: AidenRemote // Live projector: one per Bot, shared by every client. // --------------------------------------------------------------------------- -interface Subscriber { +/** What one device opted into. It changes what the device is sent, never what it may do. */ +interface Audience { + /** `bot:cards`: memory and routine proposal entries (revision 27). */ + cards: boolean; + /** `bot:activity`: the `activity` and `queued` frames and snapshot keys (revision 30). */ + activity: boolean; +} + +interface Subscriber extends Audience { frames: string[]; handle?: CursorSseHandle; overflowed: boolean; - /** The device negotiated `bot:cards` and may receive memory and routine proposal entries. */ - cards: boolean; +} + +/** Frames only `bot:activity` devices can parse. */ +const ACTIVITY_FRAME_TYPES: ReadonlySet = new Set(["activity", "queued"]); + +/** What a projector needs to project revision 30; absent on hosts without `bot-activity-v1`. */ +interface ProjectorActivity { + /** `AidenRemoteBotSessionRuntime.quietInputs` for this Bot. */ + classify(submissionIds: readonly string[]): Promise>; + /** `AidenRemoteBotSessionRuntime.placedSubmissions` for this Bot. */ + placed(): Promise>; + now(): number; + /** Shared with the desktop feed: a projector opened mid-run keeps the run's start. */ + runStarts: BotRunStarts; + retryMs: number; + report(error: unknown): void; } class BotLiveProjector { @@ -374,11 +593,43 @@ class BotLiveProjector { state: AidenRemoteBotSessionStateView = { state: "idle", interrupted: false }; question: AidenRemoteBotQuestion | null = null; approval: AidenRemoteBotApproval | null = null; + /** What the running turn is doing, in the §11 shape; null while no turn runs. */ + activity: AidenRemoteBotSessionActivity | null = null; + /** The person's messages waiting for the running turn. */ + queued: AidenRemoteBotQueuedMessage[] = []; readonly subscribers = new Set(); private view: ConversationView | undefined; private stopWatch: (() => Promise) | undefined; private lane: Promise = Promise.resolve(); private idleTimer: ReturnType | undefined; + /** When the current run started, on this Mac's clock. */ + private runStartedAt = 0; + /** Identity of the current run (`liveRunKey`): a queued follow-up replaces the run while the state stays running. */ + private runKey: string | null = null; + /** Stop was pressed during this run. */ + private stopping = false; + /** A Stop was heard; it applies only if the published state is running. */ + private stopRequested = false; + /** This run is a Resume and has not moved on yet. */ + private resuming = false; + /** `pi.live` when the Resume was heard, serialized once (`BotLiveActivityInput.resumedFrom`). */ + private resumedFrom: string | undefined; + /** The session resumed: the run it publishes as running reads "Picking up where it left off". */ + private resumeRequested = false; + /** Which queued inputs are the person's own (the desktop feed's classifier). */ + private readonly classifier: QueuedInputClassifier | undefined; + /** + * Entry id → submission id of the person's messages placed while this + * projector lived. A finished turn's submission drops out of the session, + * but its bubble keeps the id while it is in view, so a reconnect snapshot + * still names it. Pruned to the entries in the session window, so it stays + * bounded by that window. + */ + private readonly submissionOf = new Map(); + /** The newest `pi.user` entry already looked up, so each message is asked about once. */ + private placedThrough = -1; + /** The last placement lookup failed: a failing streak is reported once. */ + private placedFailing = false; closed = false; constructor( @@ -389,17 +640,42 @@ class BotLiveProjector { private readonly onClosed: () => void, private readonly readQuestion: () => AidenRemoteBotQuestion | null = () => null, private readonly readApproval: () => AidenRemoteBotApproval | null = () => null, - ) {} + /** Revision 30; without it nothing is derived and no activity or queued frame is ever sent. */ + private readonly activityDeps?: ProjectorActivity, + ) { + this.classifier = activityDeps + ? createQueuedInputClassifier({ + classify: (ids) => activityDeps.classify(ids), + now: () => activityDeps.now(), + retryMs: activityDeps.retryMs, + report: (error) => activityDeps.report(error), + // One retry after the pause, on the serial lane, even if no frame arrives. + retry: () => { + void this.serial(async () => { + if (!this.closed && this.view) await this.publish(this.view); + }); + }, + }) + : undefined; + } async attach(conversation: Conversation): Promise { const watch = await conversation.watch(ctx); this.stopWatch = () => watch.stop(); this.view = watch.value; - this.entries = await this.resolveEntries(projectBotSessionEntries(watch.value.entries)); + this.entries = await this.resolveEntries( + projectBotSessionEntries(watch.value.entries, await this.submissionsFor(watch.value.entries)), + ); + this.pruneSubmissions(this.entries); this.partial = projectBotSessionPartial(watch.value); this.state = await this.readState(); this.question = this.readQuestion(); this.approval = this.readApproval(); + if (this.activityDeps) { + this.trackRun({ state: "idle", interrupted: false }, this.state, watch.value.docs); + this.activity = this.deriveActivity(watch.value, this.state, this.question !== null || this.approval !== null); + this.queued = await this.queuedFor(watch.value.docs); + } watch.start(async (view) => { await this.advance(view); }); @@ -407,23 +683,57 @@ class BotLiveProjector { this.armIdle(); } - /** The session as a device sees it: without card entries unless it holds `bot:cards`. */ - snapshot(cards: boolean): AidenRemoteBotSession { + /** + * The session as one device sees it, inside the snapshot budget by + * construction. Parts are allocated in priority order: + * 1. identity, state, question, approval and activity ship whole (measured + * first; their protocol maxima sit well under half the budget); + * 2. the partial, cut to its tail if it no longer fits; + * 3. the queued list, oldest first, at most `MAX_QUEUED_JSON_BYTES` and what is left; + * 4. the transcript window, with whatever is left (possibly none). + */ + snapshot(audience: Audience): AidenRemoteBotSession { + const showActivity = audience.activity && this.activityDeps !== undefined; + let used = 2 + memberBytes("botId", this.botId) + memberBytes("epoch", this.epoch) + memberBytes("seq", this.seq); + for (const [key, value] of Object.entries(this.state)) used += memberBytes(key, value); + used += memberBytes("question", this.question) + memberBytes("approval", this.approval); + if (showActivity) used += memberBytes("activity", this.activity); + + let partial: string | undefined; + if (this.partial !== undefined) { + partial = tailWithinJsonBytes(this.partial, SNAPSHOT_BUDGET_BYTES - used - memberBytes("partial", "")); + used += memberBytes("partial", partial); + } + + let queued: AidenRemoteBotQueuedMessage[] | undefined; + if (showActivity) { + // "[]" is already counted in the member's empty form, hence the + 2. + const arrayBudget = SNAPSHOT_BUDGET_BYTES - used - memberBytes("queued", []) + 2; + queued = oldestThatFit(this.queued, Math.min(MAX_QUEUED_JSON_BYTES, arrayBudget)); + used += memberBytes("queued", queued); + } + + // The transcript's members are reserved empty; `hasOlder` takes its larger, `false`, form. + const transcriptBudget = + SNAPSHOT_BUDGET_BYTES - used - memberBytes("entries", []) - memberBytes("hasOlder", false) + 2; + const { entries, hasOlder } = windowed(entriesFor(this.entries, audience), transcriptBudget); return parseAidenRemoteBotSession({ botId: this.botId, epoch: this.epoch, seq: this.seq, ...this.state, - ...(this.partial !== undefined ? { partial: this.partial } : {}), - ...windowed(cards ? this.entries : withoutBotCards(this.entries)), + ...(partial !== undefined ? { partial } : {}), + entries, + hasOlder, question: this.question, approval: this.approval, + ...(showActivity ? { activity: this.activity, queued } : {}), }); } /** The `snapshot` frame for the current seq (first frame of a stream, or a history rewrite). */ - snapshotFrame(cards: boolean): string { - const session = this.snapshot(cards); + snapshotFrame(audience: Audience): string { + const session = this.snapshot(audience); return sseFrame(`${session.epoch}:${session.seq}`, "snapshot", { protocolVersion: AIDEN_REMOTE_PROTOCOL_VERSION, botId: session.botId, @@ -434,14 +744,21 @@ class BotLiveProjector { }); } - /** `legacy` is the same frame as a device without `bot:cards` must see it. */ - private broadcast(text: string, legacy: string = text): void { + /** Renders a frame once per kind of audience and queues it for every subscriber. */ + private broadcast(render: (audience: Audience) => string): void { + const rendered = new Map(); for (const subscriber of this.subscribers) { if (subscriber.frames.length >= MAX_PENDING_FRAMES) { // A stalled client is dropped; it reconnects and gets a fresh snapshot. subscriber.overflowed = true; } else { - subscriber.frames.push(subscriber.cards ? text : legacy); + const key = `${subscriber.cards}:${subscriber.activity}`; + let text = rendered.get(key); + if (text === undefined) { + text = render(subscriber); + rendered.set(key, text); + } + subscriber.frames.push(text); } subscriber.handle?.wake(); } @@ -458,18 +775,25 @@ class BotLiveProjector { }; const id = `${this.epoch}:${this.seq}`; const text = sseFrame(id, event.type, data); - // A card entry must never reach a revision-26 parser, but its seq must: - // clients refetch on any gap. Such a device gets the state it already - // holds under that seq instead, which changes nothing it shows. const card = event.type === "entry" && (AIDEN_REMOTE_BOT_CARD_ENTRY_TYPES as readonly string[]).includes( (event.payload as { entry?: { type?: string } }).entry?.type ?? "", ); - this.broadcast( - text, - card ? sseFrame(id, "state", { ...data, type: "state", payload: this.state }) : text, - ); + const activityOnly = ACTIVITY_FRAME_TYPES.has(event.type); + // A frame a device can't parse (a card entry before revision 27, an + // activity or queued frame without `bot:activity`) must still reach it as + // its seq: clients refetch on any gap. That device gets the state it + // already holds under that seq instead, which changes nothing it shows. + const noOp = card || activityOnly ? sseFrame(id, "state", { ...data, type: "state", payload: this.state }) : text; + // A bubble tagged with its submission id reaches other devices without the key. + const entry = event.type === "entry" ? (event.payload as { entry: AidenRemoteBotSessionEntry }).entry : undefined; + const bare = entry && withoutSubmissionId(entry); + const untagged = bare && bare !== entry ? sseFrame(id, "entry", { ...data, payload: { entry: bare } }) : text; + this.broadcast((audience) => { + if ((card && !audience.cards) || (activityOnly && !audience.activity)) return noOp; + return audience.activity ? text : untagged; + }); } /** @@ -501,17 +825,162 @@ class BotLiveProjector { }); } + /** + * Stop, a Stop that failed, or a Resume the session carried out, from any + * surface. A Stop applies only if the state it publishes with is running, + * so a Stop with nothing running changes nothing. + */ + noteIntent(intent: BotTurnIntent): Promise { + return this.serial(async () => { + if (!this.activityDeps) return; + if (intent === "stopping") this.stopRequested = true; + else if (intent === "stopCancelled") { + this.stopRequested = false; + this.stopping = false; + } else this.resumeRequested = true; + if (this.view) await this.publish(this.view); + }); + } + + /** + * Scope Stop, Resume and the start time to one run, like the desktop feed: a + * run starts when the state turns running or when a queued follow-up + * replaces the run (a new `liveRunKey`) while it stays running. + */ + private trackRun( + previous: AidenRemoteBotSessionStateView, + next: AidenRemoteBotSessionStateView, + docs: Readonly>, + ): void { + const runKey = liveRunKey(docs); + const deps = this.activityDeps!; + if (next.state === "running") { + const replaced = runKey !== null && this.runKey !== null && runKey !== this.runKey; + if (previous.state !== "running" || replaced) { + this.stopping = false; + this.resuming = false; + } + // Asked on every running frame, so the run's key is recorded as soon as + // it shows. Not the attach time: a phone that opens mid-run sees how long + // the run has really gone, and a replaced run gets its own start. + this.runStartedAt = deps.runStarts.startedAt(this.botId, runKey, () => deps.now()); + if (this.resumeRequested) { + this.resumeRequested = false; + this.resuming = true; + this.resumedFrom = JSON.stringify(docs["pi.live"]); + } + if (this.stopRequested) this.stopping = true; + } else { + this.stopping = false; + this.resuming = false; + deps.runStarts.ended(this.botId); + // A Resume is heard before the session publishes the run it resumed, so a + // paused state still on its way out keeps it; any other state ends it. + if (next.state !== "interrupted") this.resumeRequested = false; + } + this.stopRequested = false; + this.runKey = runKey; + } + + /** Slice B's derivation from `pi.live`, cut to the wire. A paused Bot keeps `pi.live.run` but is not working. */ + private deriveActivity( + view: ConversationView, + state: AidenRemoteBotSessionStateView, + waiting: boolean, + ): AidenRemoteBotSessionActivity | null { + if (state.state !== "running") return null; + const live = liveBotActivity({ + entries: view.entries, + docs: view.docs, + waiting, + stopping: this.stopping, + resuming: this.resuming, + resumedFrom: this.resumedFrom, + startedAt: this.runStartedAt, + }) ?? { phase: "preparing" as const, startedAt: this.runStartedAt }; + if (live.phase !== "resuming") this.resuming = false; + return projectBotSessionActivity(live); + } + + /** + * The person's queued messages (the desktop's `queuedFor`): an id not yet + * known to be the person's stays off the wire (fail closed), so a routine's + * prompt never shows as their message. + */ + private async queuedFor(docs: Readonly>): Promise { + return projectBotSessionQueued(docs, await this.classifier!.personal(docs)); + } + + /** + * The submission ids of the person's messages, by entry id (revision 30). + * Looked up on the serial lane when a new `pi.user` entry appears: its input + * is placed in the same commit, so the session still lists it unless its + * turn already ended. An id that can't be found is left out; phones then + * match the bubble by its text. + */ + private async submissionsFor(entries: readonly EntryRecord[]): Promise> { + if (!this.activityDeps) return this.submissionOf; + let newest = this.placedThrough; + for (const record of entries) { + if (record.kind === "pi.user" && Number(record.id) > newest) newest = Number(record.id); + } + if (newest <= this.placedThrough) return this.submissionOf; + this.placedThrough = newest; + let placed: ReadonlyMap; + try { + placed = await this.activityDeps.placed(); + this.placedFailing = false; + } catch (error) { + // The field is optional: without it the phone matches this bubble by its text. + if (!this.placedFailing) this.activityDeps.report(error); + this.placedFailing = true; + return this.submissionOf; + } + for (const [entryId, submissionId] of placed) { + if (WIRE_SUBMISSION_ID.test(submissionId)) this.submissionOf.set(entryId, submissionId); + } + return this.submissionOf; + } + + /** How many bubble tags this projector holds (the memo's size, for tests of its bound). */ + get submissionTagCount(): number { + return this.submissionOf.size; + } + + /** Forget the ids of bubbles that left the session window: no snapshot can show them again. */ + private pruneSubmissions(entries: readonly AidenRemoteBotSessionEntry[]): void { + if (this.submissionOf.size === 0) return; + // Either window: a device without `bot:cards` sees further back past the cards it skips. + const inView = new Set( + [...entries.slice(-AIDEN_REMOTE_BOT_SESSION_MAX_ENTRIES), ...withoutBotCards(entries).slice(-AIDEN_REMOTE_BOT_SESSION_MAX_ENTRIES)] + .map((entry) => entry.id), + ); + for (const entryId of [...this.submissionOf.keys()]) { + if (!inView.has(wireEntryId(entryId))) this.submissionOf.delete(entryId); + } + } + private async publish(view: ConversationView): Promise { if (this.closed) return; - const next = await this.resolveEntries(projectBotSessionEntries(view.entries)); + const next = await this.resolveEntries(projectBotSessionEntries(view.entries, await this.submissionsFor(view.entries))); const partial = projectBotSessionPartial(view); const state = await this.readState().catch(() => this.state); const question = this.readQuestion(); const approval = this.readApproval(); + const queued = this.activityDeps ? await this.queuedFor(view.docs) : []; if (this.closed) return; + let activity: AidenRemoteBotSessionActivity | null = null; + if (this.activityDeps) { + this.trackRun(this.state, state, view.docs); + activity = this.deriveActivity(view, state, question !== null || approval !== null); + } const previous = this.entries; - const previousById = new Map(previous.map((entry) => [entry.id, JSON.stringify(entry)])); + this.pruneSubmissions(next); + // A bubble's tag is set when it first shows and only dropped once it leaves + // the window, so a tag change alone is never news: re-sending an old bubble + // would make phones that no longer hold it append it at the bottom. + const previousById = new Map(previous.map((entry) => [entry.id, JSON.stringify(withoutSubmissionId(entry))])); const nextIds = new Set(next.map((entry) => entry.id)); // Appends and in-place updates are entry events; anything that removes or // reorders shown history (a `[SILENT]` routine turn hiding its input) is a snapshot. @@ -525,12 +994,14 @@ class BotLiveProjector { this.state = state; this.question = question; this.approval = approval; + this.activity = activity; + this.queued = queued; this.seq += 1; - this.broadcast(this.snapshotFrame(true), this.snapshotFrame(false)); + this.broadcast((audience) => this.snapshotFrame(audience)); return; } for (const entry of next) { - if (previousById.get(entry.id) === JSON.stringify(entry)) continue; + if (previousById.get(entry.id) === JSON.stringify(withoutSubmissionId(entry))) continue; this.frame({ type: "entry", payload: { entry } }); } if (partial !== this.partial) { @@ -551,6 +1022,15 @@ class BotLiveProjector { this.approval = approval; this.frame({ type: "approval", payload: { approval } }); } + // After the state frame, so a phone never sees a phase for a turn that already ended. + if (JSON.stringify(activity) !== JSON.stringify(this.activity)) { + this.activity = activity; + this.frame({ type: "activity", payload: { activity } }); + } + if (JSON.stringify(queued) !== JSON.stringify(this.queued)) { + this.queued = queued; + this.frame({ type: "queued", payload: { queued } }); + } } subscribe(subscriber: Subscriber): () => void { @@ -573,6 +1053,7 @@ class BotLiveProjector { if (this.closed) return; this.closed = true; clearTimeout(this.idleTimer); + this.classifier?.close(); this.frame({ type: "closed", payload: {} }); this.onClosed(); await this.stopWatch?.().catch(() => undefined); @@ -595,7 +1076,38 @@ function wireApproval(prompt: { waitId: string; toolCallId: string; toolName: st }; } +/** + * Refusals the session makes from its state before it changes anything: no + * host, no model (or one that can't run), or a paused turn a send won't + * replace. Each is checked before any input is written or any run resumes. + */ +const REFUSED_BEFORE_RUNNING: ReadonlySet = new Set([ + "needs_model", + "model_error", + "unavailable", + "bot_paused", +]); + +/** + * Runs one session call inside an idempotent request. A refusal from state + * leaves no record in the operation ledger, so the same key is evaluated + * again once the Bot is fixed (a model chosen, the Bot resumed) instead of + * replaying the refusal for a day. + */ +async function unlessRefusedBeforeRunning(call: () => Promise): Promise { + try { + return await call(); + } catch (error) { + if (error instanceof BotSessionError && REFUSED_BEFORE_RUNNING.has(error.reason)) { + throw new AidenOperationNotExecutedError(error); + } + throw error; + } +} + function mapSessionError(error: unknown): never { + // A ledger always unwraps it; a bare `executeIdempotent` passes it through. + if (error instanceof AidenOperationNotExecutedError) return mapSessionError(error.cause); if (error instanceof AidenRemoteServiceError) throw error; if (error instanceof BotSessionError) { switch (error.reason) { @@ -605,6 +1117,7 @@ function mapSessionError(error: unknown): never { case "access_changed": throw new AidenRemoteServiceError("capability_denied", error.message, 403); case "needs_model": + case "model_error": case "unavailable": case "bot_paused": throw new AidenRemoteServiceError("operation_stale", error.message, 409); @@ -696,14 +1209,23 @@ export class AidenRemoteBotSessionService { private readonly subscriptions = new Map>(); /** Bot approval ids answered here recently, so a retried request replays its receipt. */ private readonly answeredApprovals = new Set(); + private readonly stopIntents: (() => void) | undefined; + private readonly runStarts: BotRunStarts; constructor(private readonly options: AidenRemoteBotSessionServiceOptions) { + this.runStarts = options.runStarts ?? createBotRunStarts(); // A question or approval asked or settled is published in order with entries and state. const refresh = (botId: string) => { void this.projectors.get(botId)?.then((projector) => projector.refresh(), () => undefined); }; options.questions?.onChange(refresh); options.approvals?.onChange(refresh); + // A Stop or Resume from the Mac or any phone reaches every open session stream. + this.stopIntents = this.supportsActivity + ? options.turnIntents!.onIntent((botId, intent) => { + void this.projectors.get(botId)?.then((projector) => projector.noteIntent(intent), () => undefined); + }) + : undefined; } get supportsRoutines(): boolean { @@ -722,6 +1244,16 @@ export class AidenRemoteBotSessionService { return Boolean(this.options.memory); } + /** + * Activity and queued frames and `whenBusy` (revision 30, `bot-activity-v1`): + * Stop and Resume reach this host (`turnIntents`) and the Bot service has + * the Chat activity gate. Without both the host sends revision-29 frames and + * refuses `whenBusy`. + */ + get supportsActivity(): boolean { + return Boolean(this.options.turnIntents) && this.options.bots.supportsChatActivity === true; + } + /** Proposal answers, suggestions and the notification feed are one feature (`bot-proactive-v1`). */ get supportsProactive(): boolean { return Boolean(this.options.proposals && this.options.routineNotifications && this.options.routines); @@ -1001,6 +1533,17 @@ export class AidenRemoteBotSessionService { }, () => this.questionOf(botId), () => this.approvalOf(botId), + // Without revision 30 nothing is derived, so revision-29 streams are byte-for-byte unchanged. + this.supportsActivity + ? { + classify: (ids) => runtime.quietInputs(botId, ids), + placed: () => runtime.placedSubmissions(botId), + now: () => this.options.now?.() ?? Date.now(), + runStarts: this.runStarts, + retryMs: this.options.classifyRetryMs ?? CLASSIFY_RETRY_MS, + report: (error) => this.options.onReport?.(botId, error), + } + : undefined, ); await projector.attach(conversation); return projector; @@ -1012,28 +1555,44 @@ export class AidenRemoteBotSessionService { return created; } + /** + * Test seam: how many bubble tags the Bot's open projector remembers, or 0 + * with none open. The memo is bounded by the session window. + */ + async submissionTagsHeld(botId: string): Promise { + const projector = await this.projectors.get(botId)?.catch(() => undefined); + return projector?.submissionTagCount ?? 0; + } + /** A projector whose watch already ended is replaced by a fresh epoch. */ private async liveProjector(botId: string): Promise { const projector = await this.projector(botId); return projector.closed ? this.projector(botId) : projector; } - /** `cards`: the device negotiated `bot:cards` (memory and routine proposal entries). */ - async session(botId: string, options: { cards?: boolean } = {}): Promise { + /** What a device that negotiated these opt-ins is sent; `activity` needs this host's revision 30. */ + private audience(options: { cards?: boolean; activity?: boolean }): Audience { + return { cards: options.cards === true, activity: options.activity === true && this.supportsActivity }; + } + + /** `cards`: the device holds `bot:cards`; `activity`: it holds `bot:activity`. */ + async session(botId: string, options: { cards?: boolean; activity?: boolean } = {}): Promise { const bot = await this.options.bots.bot(botId); + const audience = this.audience(options); try { const state = await this.stateView(bot.id); if (state.state === "unavailable") { // Bots are held by another Aiden process: nothing can be opened. return parseAidenRemoteBotSession({ botId: bot.id, epoch: "epoch_unavailable", seq: 0, ...state, entries: [], hasOlder: false, question: null, approval: null, + ...(audience.activity ? { activity: null, queued: [] } : {}), }); } const projector = await this.liveProjector(bot.id); // A state change without a commit (a desktop Stop, a lock change) is not // seen by the watch; a fetch always reports the current state. await projector.refresh(); - return projector.snapshot(options.cards === true); + return projector.snapshot(audience); } catch (error) { return mapSessionError(error); } @@ -1045,7 +1604,7 @@ export class AidenRemoteBotSessionService { botId: string, response: ServerResponse, admit: () => void = () => {}, - options: { cards?: boolean } = {}, + options: { cards?: boolean; activity?: boolean } = {}, ): Promise { const bot = await this.options.bots.bot(botId); let projector: BotLiveProjector; @@ -1062,8 +1621,8 @@ export class AidenRemoteBotSessionService { throw new AidenRemoteServiceError("operation_stale", "This Bot's chat closed. Try again.", 409, true); } admit(); - const cards = options.cards === true; - const subscriber: Subscriber = { frames: [projector.snapshotFrame(cards)], overflowed: false, cards }; + const audience = this.audience(options); + const subscriber: Subscriber = { frames: [projector.snapshotFrame(audience)], overflowed: false, ...audience }; const unsubscribe = projector.subscribe(subscriber); const handle = openCursorSse(response, { pull: () => { @@ -1095,6 +1654,7 @@ export class AidenRemoteBotSessionService { } async close(): Promise { + this.stopIntents?.(); for (const deviceId of [...this.subscriptions.keys()]) this.revokeDevice(deviceId); const projectors = await Promise.allSettled([...this.projectors.values()]); for (const result of projectors) if (result.status === "fulfilled") await result.value.close(); @@ -1107,7 +1667,12 @@ export class AidenRemoteBotSessionService { } async send(deviceId: string, botId: string, key: string, input: unknown): Promise { - const parsed = parseOrInvalid(parseAidenRemoteBotMessageRequest, input, "The Bot message is invalid."); + // `whenBusy` is revision 30: a host that does not announce it refuses the key. + const parsed = parseOrInvalid( + (value) => parseAidenRemoteBotMessageRequest(value, { allowWhenBusy: this.supportsActivity }), + input, + "The Bot message is invalid.", + ); const bot = await this.options.bots.bot(botId); try { const receipt = await this.options.bots.executeIdempotent( @@ -1116,7 +1681,12 @@ export class AidenRemoteBotSessionService { async () => { const runtime = await this.options.runtime(); // The request UUID is the session request id: a resubmission dedupes. - const sent = await runtime.send(bot.id, { text: parsed.text, requestId: `remote:${deviceId}:${key}` }); + const sent = await unlessRefusedBeforeRunning(() => runtime.send(bot.id, { + text: parsed.text, + requestId: `remote:${deviceId}:${key}`, + // Steer joins the running turn; Queue (or nothing, as before) runs after it. + ...(parsed.whenBusy ? { whenBusy: parsed.whenBusy === "steer" ? "steer" as const : "followUp" as const } : {}), + })); return { ...sent, ...projectBotSessionState(await runtime.state(bot.id)) }; }, ); @@ -1143,10 +1713,19 @@ export class AidenRemoteBotSessionService { async () => { const runtime = await this.options.runtime(); const requestId = `remote:${deviceId}:${key}`; - if (action === "resume") return projectBotSessionState(await runtime.resume(bot.id, requestId)); - if (action === "dismiss") return projectBotSessionState(await runtime.dismiss(bot.id, requestId)); - // Stop ends a running turn; a paused one belongs to Dismiss. - return projectBotSessionState(await runtime.stop(bot.id)); + if (action === "resume") { + return projectBotSessionState(await unlessRefusedBeforeRunning(() => runtime.resume(bot.id, requestId))); + } + if (action === "dismiss") { + return projectBotSessionState(await unlessRefusedBeforeRunning(() => runtime.dismiss(bot.id, requestId))); + } + // Stop ends a running turn; a paused one belongs to Dismiss. Every + // surface reads "Stopping" until the run ends, or until the Stop fails. + const stop = () => unlessRefusedBeforeRunning(() => runtime.stop(bot.id)); + const state = this.options.turnIntents + ? await stopWithIntents(this.options.turnIntents, bot.id, stop) + : await stop(); + return projectBotSessionState(state); }, ); await this.afterAction(bot.id); diff --git a/main/services/aiden-remote-bots.test.ts b/main/services/aiden-remote-bots.test.ts index 77b847d9b..f5f0c5865 100644 --- a/main/services/aiden-remote-bots.test.ts +++ b/main/services/aiden-remote-bots.test.ts @@ -93,6 +93,7 @@ function fixture( >; updateBotAccessError?: unknown; createBotError?: unknown; + chatActivity?: boolean; capabilityCatalog?: ( audienceId: string, botId?: string, @@ -145,6 +146,8 @@ function fixture( const updated = bot(existing.id, { ...input, description: input.description, + // Like the real store, an absent choice keeps the saved one. + chatActivity: input.chatActivity ?? existing.chatActivity, revision: `${existing.revision}_next`, createdAt: existing.createdAt, updatedAt: existing.updatedAt + 1, @@ -800,3 +803,42 @@ test("Remote targeted catalogs validate Bot ownership before forwarding the audi ); assert.deepEqual(calls, [["device_authorized", "bot_owned"]]); }); + +test("a phone saves Chat activity with PATCH only where bot-activity-v1 is wired", async () => { + const wired = fixture([bot("bot_1")], { chatActivity: true }); + const before = await wired.service.get("bot_1"); + assert.deepEqual(before.chatActivity, { level: "quiet", wording: "follows" }, "a Bot that never chose reads as the default"); + + const saved = await wired.service.updateIdentity("bot_1", before.revision, { + chatActivity: { level: "summary", wording: "thinking" }, + }); + assert.deepEqual(saved.chatActivity, { level: "summary", wording: "thinking" }); + assert.notEqual(saved.revision, before.revision); + assert.equal(saved.name, before.name, "only the choice changed"); + assert.deepEqual((await wired.service.get("bot_1")).chatActivity, { level: "summary", wording: "thinking" }); + + const renamed = await wired.service.updateIdentity("bot_1", saved.revision, { name: "Renamed" }); + assert.deepEqual(renamed.chatActivity, { level: "summary", wording: "thinking" }, "an absent value keeps the stored choice"); + + for (const invalid of [ + { level: "verbose", wording: "follows" }, + { level: "quiet" }, + { level: "quiet", wording: "follows", extra: true }, + ]) { + await assert.rejects( + wired.service.updateIdentity("bot_1", renamed.revision, { chatActivity: invalid }), + (error: unknown) => (error as { code?: string }).code === "invalid_request", + JSON.stringify(invalid), + ); + } +}); + +test("a Mac without bot-activity-v1 neither shows nor accepts chatActivity", async () => { + const older = fixture([bot("bot_1", { chatActivity: { level: "detailed", wording: "follows" } })]); + const detail = await older.service.get("bot_1"); + assert.equal("chatActivity" in detail, false); + await assert.rejects( + older.service.updateIdentity("bot_1", detail.revision, { chatActivity: { level: "summary", wording: "follows" } }), + (error: unknown) => (error as { code?: string }).code === "invalid_request", + ); +}); diff --git a/main/services/aiden-remote-bots.ts b/main/services/aiden-remote-bots.ts index 16622c721..a2a32820c 100644 --- a/main/services/aiden-remote-bots.ts +++ b/main/services/aiden-remote-bots.ts @@ -5,10 +5,11 @@ import { type BotAccessView, type BotCapabilityCatalog, } from "../../renderer/shared/bot-capabilities.js"; -import type { - BotCreateInput, - BotDefinition, - BotUpdateInput, +import { + resolveBotChatActivity, + type BotCreateInput, + type BotDefinition, + type BotUpdateInput, } from "../../renderer/shared/bots.js"; import { BotCapabilityCatalogConflictError, @@ -245,6 +246,8 @@ type BotApplicationPort = { export interface AidenRemoteBotServiceOptions { application: BotApplicationPort; + /** Contract revision 30 (`bot-activity-v1`): Bot detail carries `chatActivity` and `PATCH` accepts it. */ + chatActivity?: boolean; chatStore: { get(chatId: string): Promise }; /** Permanently erases a Bot (the durable session's `deleteBot`). */ deleteBot?(botId: string): Promise; @@ -285,6 +288,11 @@ export class AidenRemoteBotService { this.idempotency = options.idempotency ?? new AidenIdempotencyLedger(); } + /** Revision 30: whether Bot detail carries `chatActivity` and `PATCH` accepts it. */ + get supportsChatActivity(): boolean { + return this.options.chatActivity === true; + } + /** Shared with the durable-session routes so every Bot POST uses one ledger. */ executeIdempotent( scope: { deviceId: string; route: string; resourceId: string; key: string }, @@ -472,6 +480,7 @@ export class AidenRemoteBotService { access: parseAidenRemoteBotAccessView(access), ...(modelSelection ? { modelSelection } : {}), ...(visionModelSelection ? { visionModelSelection } : {}), + ...(this.supportsChatActivity ? { chatActivity: resolveBotChatActivity(bot.chatActivity) } : {}), }); } @@ -665,7 +674,8 @@ export class AidenRemoteBotService { audienceId?: string, ): Promise { const parsed = parseRequest( - parseAidenRemoteBotIdentityPatchRequest, + (value: unknown) => + parseAidenRemoteBotIdentityPatchRequest(value, { allowChatActivity: this.supportsChatActivity }), input, "The Bot identity update is invalid.", ); @@ -693,6 +703,8 @@ export class AidenRemoteBotService { : existing.description ? { description: existing.description } : {}), instructions: parsed.instructions ?? existing.instructions, avatar: recipe(parsed.avatar ?? existing.avatar), + // Absent keeps the saved choice (slice B ruling 2); only an explicit pick changes it. + ...(parsed.chatActivity ? { chatActivity: parsed.chatActivity } : {}), }); this.options.notifyBotsChanged?.(updated.id); return this.detail(updated, audienceId); diff --git a/main/services/aiden-remote-operation-contract.test.ts b/main/services/aiden-remote-operation-contract.test.ts index 4a8b2fbb9..de8384e4d 100644 --- a/main/services/aiden-remote-operation-contract.test.ts +++ b/main/services/aiden-remote-operation-contract.test.ts @@ -897,7 +897,7 @@ test("canonical revision-14 fixtures parse into explicit bounded contract views" const source = await readBotContractFixture(); const fixture = parseAidenRemoteContractFixture(source); - assert.equal(fixture.contractRevision, 29); + assert.equal(fixture.contractRevision, 30); assert.equal(fixture.botList.maxBots, 256); assert.equal(fixture.botSummary.health, "ready"); assert.equal(fixture.botDetail.access.botId, fixture.botDetail.id); diff --git a/main/services/aiden-remote-operation-contract.ts b/main/services/aiden-remote-operation-contract.ts index 7095e7a92..d85d44d8b 100644 --- a/main/services/aiden-remote-operation-contract.ts +++ b/main/services/aiden-remote-operation-contract.ts @@ -43,6 +43,19 @@ export class AidenOperationUnknownOutcomeError extends Error { } } +/** + * The action refused before it changed anything (a state check such as a + * paused Bot or a missing model). The ledger forgets the admission instead of + * recording a rejection, so a retry with the same key is evaluated afresh, and + * the caller sees `cause`. Throw it only when nothing can have committed. + */ +export class AidenOperationNotExecutedError extends Error { + constructor(readonly cause: unknown) { + super("The operation was refused before it ran."); + this.name = "AidenOperationNotExecutedError"; + } +} + export interface AidenIdempotencySnapshotEntry { scopeDigest: string; requestDigest: string; @@ -737,6 +750,11 @@ export class AidenIdempotencyLedger { this.retainUnknownOutcome(entry); throw new AidenOperationContractError("idempotency_in_flight"); } + if (error instanceof AidenOperationNotExecutedError) { + // Nothing ran: no rejection is recorded, so the same key re-evaluates next time. + if (this.entries.get(ledgerKey) === entry) this.entries.delete(ledgerKey); + throw error.cause; + } this.settleRejected(ledgerKey, entry, error); throw error; }, diff --git a/main/services/aiden-remote-pairing.test.ts b/main/services/aiden-remote-pairing.test.ts index 37c5239d5..ea29b0f25 100644 --- a/main/services/aiden-remote-pairing.test.ts +++ b/main/services/aiden-remote-pairing.test.ts @@ -15,7 +15,7 @@ import { AIDEN_REMOTE_PROGRESS_CAPABILITIES, AIDEN_REMOTE_SIMULATOR_CAPABILITIES, AIDEN_REMOTE_MOBILE_SIMULATOR_CAPABILITIES, - AIDEN_REMOTE_BOT_CARD_CAPABILITIES, + AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES, } from "./aiden-remote-protocol.js"; const endpoint = "https://aiden.example.test/api/aiden/v1"; @@ -314,8 +314,8 @@ test("pairing grants progress authority only to clients that explicitly accept i !(AIDEN_REMOTE_SIMULATOR_CAPABILITIES as readonly string[]).includes(capability) && !(AIDEN_REMOTE_MOBILE_SIMULATOR_CAPABILITIES as readonly string[]).includes(capability) && !(AIDEN_REMOTE_HOST_CAPABILITIES as readonly string[]).includes(capability) && - // bot:cards (revision 27) is negotiated after pairing, never issued by it. - !(AIDEN_REMOTE_BOT_CARD_CAPABILITIES as readonly string[]).includes(capability), + // bot:cards (revision 27) and bot:activity (revision 30) are negotiated after pairing, never issued by it. + !(AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES as readonly string[]).includes(capability), ), ); assert.equal((fullResult.capabilities as readonly string[]).includes("simulators:control"), false); diff --git a/main/services/aiden-remote-protocol.test.ts b/main/services/aiden-remote-protocol.test.ts index d04cad2c4..3ea25537b 100644 --- a/main/services/aiden-remote-protocol.test.ts +++ b/main/services/aiden-remote-protocol.test.ts @@ -5,8 +5,13 @@ import path from "node:path"; import test from "node:test"; import { BOT_AVATAR_COLORS, BOT_AVATAR_COLOR_HEX, BOT_AVATAR_FACE_HEX } from "../../renderer/shared/bots.js"; import { parseGenerationTimeline } from "../../renderer/shared/generation-timeline.js"; +import { BOT_ACTIVITY_PHASES, botActivityDisplay, botElapsedLabel } from "../../renderer/shared/bot-activity.js"; import { parseAidenRemoteBotSession, + parseAidenRemoteBotSessionActivity, + parseAidenRemoteBotDetail, + parseAidenRemoteBotIdentityPatchRequest, + parseAidenRemoteBotMessageRequest, parseAidenRemoteBotSessionEntry, parseAidenRemoteBotSessionEvent, AIDEN_REMOTE_BASE_PATH, @@ -14,7 +19,7 @@ import { AIDEN_REMOTE_CAPABILITIES, AIDEN_REMOTE_SIMULATOR_CAPABILITIES, AIDEN_REMOTE_MOBILE_SIMULATOR_CAPABILITIES, - AIDEN_REMOTE_BOT_CARD_CAPABILITIES, + AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES, AIDEN_REMOTE_HOST_CAPABILITIES, AIDEN_REMOTE_NEGOTIABLE_CAPABILITIES, AIDEN_REMOTE_HOST_FEED_EVENT_TYPES, @@ -106,13 +111,13 @@ const endpointAuthorityVectors: readonly [string, boolean][] = [ ]; // Simulator control is a desktop-to-desktop grant that pairing never issues, -// and the phone simulator viewer grant and the Bot card opt-in are negotiated -// only after pairing. +// and the phone simulator viewer grant and the Bot card and activity opt-ins are +// negotiated only after pairing. const PAIRING_CAPABILITIES = AIDEN_REMOTE_CAPABILITIES.filter( (capability) => !(AIDEN_REMOTE_SIMULATOR_CAPABILITIES as readonly string[]).includes(capability) && !(AIDEN_REMOTE_MOBILE_SIMULATOR_CAPABILITIES as readonly string[]).includes(capability) && - !(AIDEN_REMOTE_BOT_CARD_CAPABILITIES as readonly string[]).includes(capability), + !(AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES as readonly string[]).includes(capability), ); // Host-wide grants are issued to desktops only, so phones are never told they // exist either and the shared mobile fixture omits both families. @@ -137,7 +142,7 @@ test("every server feature token the host can announce fits the server feature b test("shared Aiden Remote v1 fixture is complete, ordered, and contains no unsafe wire keys", async () => { const fixture = parseAidenRemoteContractFixture(await json("fixtures/contract.json")); - assert.equal(fixture.contractRevision, 29); + assert.equal(fixture.contractRevision, 30); assert.match(JSON.stringify(fixture.events), /"producedFile":\{"relativePath":"out\/report.txt","operation":"written","bytes":12\}/u); const fixtureTimeline = fixture.events.find((event) => event.type === "timeline"); const parsedTimeline = parseGenerationTimeline(record(fixtureTimeline?.payload, "timeline payload").timeline); @@ -430,6 +435,9 @@ test("pairing request fixtures only carry a sealed envelope for an approved requ const { chatId, revision, messages, hasOlder } = record(revision19.messagesWindow, "messagesWindow"); revision19.messagesWindow = { chatId, revision, messages, hasOlder }; delete revision19.chatFork; + // Bot chat activity is revision 30. + delete revision19.botActivity; + delete revision19.botActivityPrivacy; // Message visuals are revision 28. for (const message of record(revision19.chat, "chat").messages as Record[]) delete message.visuals; assert.throws(() => parseAidenRemoteContractFixture(revision19), /require contract revision 20/u); @@ -1124,7 +1132,7 @@ test("Bot OpenAPI freezes bounded DTOs, conjunctive grants, and privacy-safe rou ); const identityPatch = record(schemas.BotIdentityPatch, "BotIdentityPatch"); assert.equal(identityPatch.minProperties, 1); - assert.deepEqual(Object.keys(record(identityPatch.properties, "BotIdentityPatch properties")), ["name", "purpose", "openingGreeting", "instructions", "avatar"]); + assert.deepEqual(Object.keys(record(identityPatch.properties, "BotIdentityPatch properties")), ["name", "purpose", "openingGreeting", "instructions", "avatar", "chatActivity"]); const conversationPageProperties = record(record(schemas.BotConversationPage, "BotConversationPage").properties, "BotConversationPage properties"); assert.equal(record(conversationPageProperties.conversations, "conversations").maxItems, 50); @@ -2392,3 +2400,158 @@ test("the shared Bot avatar palette fixture is exactly the desktop colour table" assert.match(value, /^#[0-9A-F]{6}$/u); } }); + +// --------------------------------------------------------------------------- +// Contract revision 30: Bot chat activity on phones +// --------------------------------------------------------------------------- + +test("Bot activity frames carry only the §11 allow-list, and every privacy sample is refused", async () => { + const raw = (await json("fixtures/contract.json")) as Record; + const fixture = parseAidenRemoteContractFixture(raw); + const section = fixture.botActivity!; + assert.deepEqual(section.session, raw.botActivity.session, "the activity session round-trips"); + assert.deepEqual(section.events, raw.botActivity.events, "every activity frame round-trips"); + const shown = [section.session.activity, ...section.events.flatMap((event) => (event.type === "activity" ? [event.payload.activity] : []))]; + for (const activity of shown) { + if (activity === null || activity === undefined) continue; + assert.deepEqual(Object.keys(activity).filter((key) => !["phase", "app", "startedAt"].includes(key)), []); + } + for (const sample of raw.botActivityPrivacy.rejectedActivities) { + assert.throws(() => parseAidenRemoteBotSessionActivity(sample), Error, JSON.stringify(sample)); + const frame = { ...raw.botActivity.events[0], payload: { activity: sample } }; + assert.throws(() => parseAidenRemoteBotSessionEvent(frame), Error, JSON.stringify(sample)); + } + // A few samples are pinned to their reason, so a TypeError can never pass for a refusal. + const refusedWith = (field: string, pattern: RegExp) => { + const sample = raw.botActivityPrivacy.rejectedActivities.find((candidate: Record) => field in candidate); + assert.throws(() => parseAidenRemoteBotSessionActivity(sample), pattern, field); + }; + refusedWith("detail", /unsupported field detail/u); + refusedWith("step", /unsupported field step/u); + refusedWith("query", /unsupported field query/u); + for (const sample of raw.botActivityPrivacy.rejectedQueued) { + assert.throws(() => parseAidenRemoteBotSession({ ...raw.botActivity.session, queued: [sample] }), Error, JSON.stringify(sample)); + } + const [duplicateIds, twentyOneItems] = raw.botActivityPrivacy.rejectedQueuedLists; + assert.equal(duplicateIds.length, 2); + assert.equal(twentyOneItems.length, 21); + assert.throws(() => parseAidenRemoteBotSession({ ...raw.botActivity.session, queued: duplicateIds }), /ids must be unique/u); + assert.throws(() => parseAidenRemoteBotSession({ ...raw.botActivity.session, queued: twentyOneItems }), /at most 20/u); + assert.throws(() => parseAidenRemoteBotSession({ ...raw.botActivity.session, queued: "not a list" }), /must be an array/u); + const repeated = ({ unit, count }: { unit: string; count: number }) => unit.repeat(count); + const bounds = raw.botActivityPrivacy.textBounds; + const queuedWith = (text: string) => ({ ...raw.botActivity.session, queued: [{ submissionId: "18", mode: "queue", text }] }); + for (const sample of bounds.queuedText.rejected) { + assert.throws(() => parseAidenRemoteBotSession(queuedWith(repeated(sample))), /characters/u, `${sample.count} x ${sample.unit}`); + } + for (const sample of bounds.queuedText.accepted) { + assert.equal(parseAidenRemoteBotSession(queuedWith(repeated(sample))).queued![0]!.text, repeated(sample)); + } + const appWith = (app: string) => ({ phase: "app", app, startedAt: 1_760_450_000_000 }); + for (const sample of bounds.app.rejected) { + assert.throws(() => parseAidenRemoteBotSessionActivity(appWith(repeated(sample))), /characters/u, `${sample.count} x ${sample.unit}`); + } + for (const sample of bounds.app.accepted) { + assert.equal(parseAidenRemoteBotSessionActivity(appWith(repeated(sample))).app, repeated(sample)); + } + for (const sample of raw.botActivityPrivacy.rejectedChatActivity) { + assert.throws(() => parseAidenRemoteBotDetail({ ...raw.botActivity.detail, chatActivity: sample }), Error, JSON.stringify(sample)); + assert.throws(() => parseAidenRemoteBotIdentityPatchRequest({ chatActivity: sample }, { allowChatActivity: true })); + } +}); + +test("an optional Bot activity key sent as the literal null is refused, never read as absent", async () => { + const raw = (await json("fixtures/contract.json")) as Record; + const { activities, sessions, events, entries } = raw.botActivityPrivacy.rejectedNullLiterals; + assert.deepEqual(activities.map((sample: Record) => sample.app), [null]); + assert.deepEqual(sessions.map((sample: Record) => sample.queued), [null]); + assert.deepEqual(events.map((sample: Record) => sample.payload.queued), [null]); + assert.deepEqual(entries.map((sample: Record) => [sample.label, sample.submissionId]), [[null, "17"]]); + for (const sample of activities) { + assert.throws(() => parseAidenRemoteBotSessionActivity(sample), /app must be/u, JSON.stringify(sample)); + const { app: _app, ...withoutApp } = sample; + assert.deepEqual(parseAidenRemoteBotSessionActivity(withoutApp), withoutApp, "the same activity without the key is fine"); + } + for (const sample of sessions) { + assert.throws(() => parseAidenRemoteBotSession(sample), /queued messages must be an array/u); + const { queued: _queued, ...withoutQueued } = sample; + assert.equal("queued" in parseAidenRemoteBotSession(withoutQueued), false, "an absent list stays absent"); + } + for (const sample of events) { + assert.throws(() => parseAidenRemoteBotSessionEvent(sample), /queued messages must be an array/u); + } + for (const sample of entries) { + assert.throws(() => parseAidenRemoteBotSessionEntry(sample), /Only the person's message can carry a submissionId/u); + const { label: _label, ...withoutLabel } = sample; + const entry = parseAidenRemoteBotSessionEntry(withoutLabel); + assert.equal(entry.type === "message" && entry.submissionId, "17", "the same bubble without the key keeps its id"); + } +}); + +test("the person's own message carries its submissionId for bot:activity devices only, and every misuse is refused", async () => { + const raw = (await json("fixtures/contract.json")) as Record; + const fixture = parseAidenRemoteContractFixture(raw); + const section = fixture.botActivity!; + const sent = section.session.entries[0]!; + assert.equal(sent.type === "message" && sent.role === "user" && sent.submissionId, "17"); + // The queued message 18 was placed in the turn: its bubble now carries the same id. + assert.deepEqual(section.entryEvent, raw.botActivity.entryEvent, "the entry frame round-trips"); + const placed = section.entryEvent.type === "entry" ? section.entryEvent.payload.entry : undefined; + assert.equal(placed?.type === "message" && placed.submissionId, raw.botActivity.session.queued[0].submissionId); + + // What a device without bot:activity is sent for the same session and frame: no new key anywhere. + const { nonHolder } = raw.botActivityPrivacy; + const legacy = parseAidenRemoteBotSession(nonHolder.session); + const legacyFrame = parseAidenRemoteBotSessionEvent(nonHolder.entryEvent); + for (const value of [legacy, legacyFrame]) { + assert.equal(JSON.stringify(value).includes("submissionId"), false); + } + assert.equal("activity" in legacy || "queued" in legacy, false); + const withoutId = (entry: Record) => { + const { submissionId: _submissionId, ...rest } = entry; + return rest; + }; + assert.deepEqual(legacy.entries, raw.botActivity.session.entries.map(withoutId), "the same messages, without their ids"); + assert.equal(legacyFrame.seq, raw.botActivity.entryEvent.seq, "the same seq"); + assert.deepEqual(nonHolder.entryEvent.payload.entry, withoutId(raw.botActivity.entryEvent.payload.entry)); + + for (const sample of raw.botActivityPrivacy.rejectedSubmissionIdEntries) { + assert.throws(() => parseAidenRemoteBotSessionEntry(sample), Error, JSON.stringify(sample)); + } + const [assistantWithId, routineWithId] = raw.botActivityPrivacy.rejectedSubmissionIdEntries; + assert.throws(() => parseAidenRemoteBotSessionEntry(assistantWithId), /Only the person's message can carry a submissionId/u); + assert.equal(routineWithId.label, "Morning brief", "a routine's input"); + assert.throws(() => parseAidenRemoteBotSessionEntry(routineWithId), /Only the person's message can carry a submissionId/u); + const { submissionId: _routineId, ...routine } = routineWithId; + assert.deepEqual(parseAidenRemoteBotSessionEntry(routine), routine, "the same routine bubble without an id is fine"); +}); + +test("whenBusy and chatActivity are refused unless the host wires bot-activity-v1", async () => { + const raw = (await json("fixtures/contract.json")) as Record; + const send = raw.botActivity.send.request; + assert.throws(() => parseAidenRemoteBotMessageRequest(send), /unsupported field whenBusy/u); + assert.deepEqual(parseAidenRemoteBotMessageRequest(send, { allowWhenBusy: true }), send); + assert.deepEqual(parseAidenRemoteBotMessageRequest({ text: "Hi" }, { allowWhenBusy: true }), { text: "Hi" }); + assert.throws(() => parseAidenRemoteBotMessageRequest({ text: "Hi", whenBusy: "followUp" }, { allowWhenBusy: true })); + const patch = raw.botActivity.identityPatch.request; + assert.throws(() => parseAidenRemoteBotIdentityPatchRequest(patch), /unsupported field chatActivity/u); + assert.deepEqual(parseAidenRemoteBotIdentityPatchRequest(patch, { allowChatActivity: true }), patch); +}); + +test("the shared phase table is the one the Mac draws, and an unknown phase reads as Working", async () => { + const raw = (await json("fixtures/contract.json")) as Record; + const { phases, thinkingKeeps, app, elapsed } = raw.botActivity as { + phases: Array<{ id: string; word: string; mark: string }>; + thinkingKeeps: string[]; + app: { app: string; word: string }; + elapsed: Array<{ startedAt: number; now: number; label: string | null }>; + }; + assert.deepEqual(phases.map((phase) => phase.id), [...BOT_ACTIVITY_PHASES]); + for (const phase of phases) { + assert.deepEqual(botActivityDisplay(phase.id, "follows"), { phase: phase.id, word: phase.word, mark: phase.mark }); + assert.equal(botActivityDisplay(phase.id, "thinking").phase, thinkingKeeps.includes(phase.id) ? phase.id : "thinking"); + } + assert.equal(botActivityDisplay("app", "follows", app.app).word, app.word); + assert.deepEqual(botActivityDisplay("teleporting", "follows"), botActivityDisplay("working", "follows")); + for (const row of elapsed) assert.equal(botElapsedLabel(row.startedAt, row.now), row.label, JSON.stringify(row)); +}); diff --git a/main/services/aiden-remote-protocol.ts b/main/services/aiden-remote-protocol.ts index e54ba537d..54e749cf3 100644 --- a/main/services/aiden-remote-protocol.ts +++ b/main/services/aiden-remote-protocol.ts @@ -16,6 +16,12 @@ import { CHAT_ROW_STATES } from "../../renderer/shared/chat-row-state.js"; import { DEVICE_ID_PATTERN } from "../../renderer/shared/devices.js"; import { parseBotRoutineSchedule } from "../../renderer/shared/bot-routine-schedule.js"; import { BOT_MEMORY_LIMITS } from "../../renderer/shared/bot-memory.js"; +import { + BOT_CHAT_ACTIVITY_LEVELS, + BOT_CHAT_ACTIVITY_WORDINGS, + type BotChatActivityLevel, + type BotChatActivityWording, +} from "../../renderer/shared/bots.js"; import { MAX_FORK_SUMMARY_INSTRUCTIONS_CHARS, MAX_FORK_SUMMARY_TEXT_CHARS, @@ -37,8 +43,11 @@ export const AIDEN_REMOTE_PROTOCOL_VERSION = 1 as const; * Contract revision 29: completed `ask_user_question` tool steps on a version 3 * generation timeline may carry a display-only `questionnaire` recap of the * questions and what the owner chose; older clients ignore it. + * + * Contract revision 30 (provisional; re-claim the next revision after `main`'s + * at merge): Bot chat activity on phones (`bot-activity-v1`, `bot:activity`). */ -export const AIDEN_REMOTE_CONTRACT_REVISION = 29 as const; +export const AIDEN_REMOTE_CONTRACT_REVISION = 30 as const; export const AIDEN_REMOTE_BASE_PATH = "/api/aiden/v1" as const; export const AIDEN_REMOTE_MAX_SSE_FRAME_BYTES = 1_048_576; export const AIDEN_REMOTE_MAX_JSON_RESPONSE_BYTES = 1_048_576; @@ -74,6 +83,14 @@ export const AIDEN_REMOTE_BOT_PRESETS_FEATURE = "bot-presets-v1" as const; */ export const AIDEN_REMOTE_BOT_MEMORY_FEATURE = "bot-memory-v1" as const; export const AIDEN_REMOTE_BOT_PROACTIVE_FEATURE = "bot-proactive-v1" as const; +/** + * Bot chat activity (contract revision 30). Announced only while the host + * projects the session `activity` and `queued` frames, accepts `whenBusy` on + * `POST /bots/{botId}/messages`, and serves `chatActivity` on Bot detail and + * `PATCH /bots/{botId}`. A device opts into the frames and the detail field + * with the negotiable `bot:activity`. + */ +export const AIDEN_REMOTE_BOT_ACTIVITY_FEATURE = "bot-activity-v1" as const; const AIDEN_REMOTE_MAX_IDENTIFIER_LENGTH = 128; const AIDEN_REMOTE_MAX_ENDPOINT_UTF8_BYTES = 2_048; @@ -183,6 +200,19 @@ export const AIDEN_REMOTE_BOT_CARD_CAPABILITIES = [ export type AidenRemoteBotCardCapability = (typeof AIDEN_REMOTE_BOT_CARD_CAPABILITIES)[number]; +/** + * Contract revision 30: a device holding `bot:activity` receives the session + * `activity` and `queued` frames and Bot detail `chatActivity`. Like + * `bot:cards` it grants no authority; any device type may hold it. + */ +export const AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES = ["bot:activity"] as const; + +/** Opt-ins that change what a device is sent, never what it may do. */ +export const AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES = [ + ...AIDEN_REMOTE_BOT_CARD_CAPABILITIES, + ...AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES, +] as const; + /** Vocabulary a paired device may add after pairing through `POST /device/capabilities`. */ export const AIDEN_REMOTE_NEGOTIABLE_CAPABILITIES = [ ...AIDEN_REMOTE_PROGRESS_CAPABILITIES, @@ -190,6 +220,7 @@ export const AIDEN_REMOTE_NEGOTIABLE_CAPABILITIES = [ ...AIDEN_REMOTE_HOST_CAPABILITIES, ...AIDEN_REMOTE_MOBILE_SIMULATOR_CAPABILITIES, ...AIDEN_REMOTE_BOT_CARD_CAPABILITIES, + ...AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES, ] as const; export type AidenRemoteNegotiableCapability = @@ -203,6 +234,7 @@ export const AIDEN_REMOTE_CAPABILITIES = [ ...AIDEN_REMOTE_HOST_CAPABILITIES, ...AIDEN_REMOTE_MOBILE_SIMULATOR_CAPABILITIES, ...AIDEN_REMOTE_BOT_CARD_CAPABILITIES, + ...AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES, ] as const; export type AidenRemoteCapability = (typeof AIDEN_REMOTE_CAPABILITIES)[number]; @@ -805,6 +837,8 @@ export type AidenRemoteBotDetail = AidenRemoteBotSummary & { access: AidenRemoteBotAccessView; modelSelection?: { providerId: string; modelId: string }; visionModelSelection?: { providerId: string; modelId: string }; + /** Contract revision 30, only for devices holding `bot:activity`. */ + chatActivity?: AidenRemoteBotChatActivity; }; /** @@ -833,6 +867,8 @@ export interface AidenRemoteBotIdentityPatchRequest { purpose?: string; instructions?: string; avatar?: AidenRemoteBotSemanticAvatar; + /** Contract revision 30, only on hosts announcing `bot-activity-v1`. Absent keeps the saved choice. */ + chatActivity?: AidenRemoteBotChatActivity; } export interface AidenRemoteBotConversationItemBase { @@ -969,6 +1005,8 @@ export const AIDEN_REMOTE_BOT_SESSION_EVENT_TYPES = [ "state", "question", "approval", + "activity", + "queued", "closed", ] as const; export type AidenRemoteBotSessionEventType = (typeof AIDEN_REMOTE_BOT_SESSION_EVENT_TYPES)[number]; @@ -986,6 +1024,14 @@ export type AidenRemoteBotSessionEntry = label?: string; /** An assistant answer that was cut off. */ interrupted?: true; + /** + * Contract revision 30, only for devices holding `bot:activity`: the + * submission this person's own message was sent as (the send receipt's + * and the queued list's `submissionId`), so a phone settles its + * optimistic bubble exactly. Never on a routine's input or a question + * answer, and omitted when the Mac no longer knows it. + */ + submissionId?: string; } | { type: "connect_card"; @@ -1135,6 +1181,38 @@ export interface AidenRemoteBotRoutineNotificationList { now: string; } +// --- Contract revision 30: Bot chat activity. + +export const AIDEN_REMOTE_BOT_ACTIVITY_APP_MAX_CHARS = 40; +export const AIDEN_REMOTE_BOT_QUEUED_MAX = 20; +export const AIDEN_REMOTE_BOT_WHEN_BUSY_MODES = ["steer", "queue"] as const; +export type AidenRemoteBotWhenBusy = (typeof AIDEN_REMOTE_BOT_WHEN_BUSY_MODES)[number]; +const AIDEN_REMOTE_BOT_ACTIVITY_PHASE = /^[a-z][a-z_]{0,31}$/u; +const AIDEN_REMOTE_BOT_SUBMISSION_ID = /^[A-Za-z0-9._:-]{1,64}$/u; + +/** What a Bot in a running turn is doing (spec §5). Exactly these keys cross the wire (§11). */ +export interface AidenRemoteBotSessionActivity { + /** A §5 phase id. Clients render an id they do not know as `working`. */ + phase: string; + /** The connection's label for `app`, at most 40 code points. */ + app?: string; + /** When the turn started: the Mac's clock, milliseconds since 1970. */ + startedAt: number; +} + +/** A message waiting for the running turn: `steer` joins it, `queue` runs after it. */ +export interface AidenRemoteBotQueuedMessage { + submissionId: string; + mode: AidenRemoteBotWhenBusy; + text: string; +} + +/** A Bot's Chat activity choice (spec §7). */ +export interface AidenRemoteBotChatActivity { + level: BotChatActivityLevel; + wording: BotChatActivityWording; +} + /** State of a Bot's one durable conversation. */ export interface AidenRemoteBotSessionStateView { state: AidenRemoteBotSessionStateKind; @@ -1205,6 +1283,10 @@ export interface AidenRemoteBotSession extends AidenRemoteBotSessionStateView { question: AidenRemoteBotQuestion | null; /** The oldest tool approval the Bot is waiting on, or null. */ approval: AidenRemoteBotApproval | null; + /** Revision 30, only for `bot:activity` devices: what the running turn is doing, or null. */ + activity?: AidenRemoteBotSessionActivity | null; + /** Revision 30, only for `bot:activity` devices: the person's messages waiting for the turn. */ + queued?: AidenRemoteBotQueuedMessage[]; } export type AidenRemoteBotSessionEventPayload = @@ -1214,6 +1296,8 @@ export type AidenRemoteBotSessionEventPayload = | { type: "state"; payload: AidenRemoteBotSessionStateView } | { type: "question"; payload: { question: AidenRemoteBotQuestion | null } } | { type: "approval"; payload: { approval: AidenRemoteBotApproval | null } } + | { type: "activity"; payload: { activity: AidenRemoteBotSessionActivity | null } } + | { type: "queued"; payload: { queued: AidenRemoteBotQueuedMessage[] } } | { type: "closed"; payload: Record }; /** One frame of `GET /bots/{botId}/session/events`. */ @@ -1226,6 +1310,8 @@ export type AidenRemoteBotSessionEvent = { export interface AidenRemoteBotMessageRequest { text: string; + /** Revision 30, only on hosts announcing `bot-activity-v1`. Absent queues, as before. */ + whenBusy?: AidenRemoteBotWhenBusy; } export interface AidenRemoteBotMessageReceipt extends AidenRemoteBotSessionStateView { @@ -1477,6 +1563,8 @@ export interface AidenRemoteContractFixture { botRoutineNotifications: AidenRemoteBotRoutineNotificationList; /** Revision 27: a session as a `bot:cards` device sees it. */ botSessionCards: AidenRemoteBotSession; + /** Revision 30: activity sessions, frames, Chat activity and the phase table every client draws. */ + botActivity?: AidenRemoteBotActivityFixture; taskProgress: AidenRemoteChatTaskProgress; agentRoster: AidenRemoteChatAgentRoster; agentInterrupt: AidenRemoteChatAgentInterruptFixture; @@ -2498,11 +2586,30 @@ function ignoreRetiredBotGreeting(value: Record, label: string) if (hasOwn(value, "openingGreeting")) boundedText(value.openingGreeting, `${label} openingGreeting`, 2_000, true); } +/** A Chat activity choice: exact keys, known values. */ +export function parseAidenRemoteBotChatActivity(value: unknown): AidenRemoteBotChatActivity { + if (!isRecord(value)) throw new Error("Bot chat activity must be an object."); + assertExactKeys(value, ["level", "wording"], "Bot chat activity"); + return { + level: enumMember(value.level, BOT_CHAT_ACTIVITY_LEVELS, "Bot chat activity level"), + wording: enumMember(value.wording, BOT_CHAT_ACTIVITY_WORDINGS, "Bot chat activity wording"), + }; +} + export function parseAidenRemoteBotIdentityPatchRequest( value: unknown, + options: { allowChatActivity?: boolean } = {}, ): AidenRemoteBotIdentityPatchRequest { if (!isRecord(value)) throw new Error("Bot identity patch must be an object."); - const allowed = ["name", "purpose", ...AIDEN_REMOTE_IGNORED_BOT_IDENTITY_FIELDS, "instructions", "avatar"] as const; + const allowed = [ + "name", + "purpose", + ...AIDEN_REMOTE_IGNORED_BOT_IDENTITY_FIELDS, + "instructions", + "avatar", + // Revision 30: older hosts reject the key, so phones send it only where it is announced. + ...(options.allowChatActivity === true ? ["chatActivity"] : []), + ]; assertExactKeys(value, allowed, "Bot identity patch"); if (Object.keys(value).length === 0) { throw new Error("Bot identity patch must change at least one field."); @@ -2522,6 +2629,9 @@ export function parseAidenRemoteBotIdentityPatchRequest( ...(hasOwn(value, "avatar") ? { avatar: parseBotSemanticAvatar(value.avatar, true) } : {}), + ...(hasOwn(value, "chatActivity") + ? { chatActivity: parseAidenRemoteBotChatActivity(value.chatActivity) } + : {}), }; } @@ -3520,6 +3630,9 @@ export function parseAidenRemoteBotDetail(value: unknown): AidenRemoteBotDetail })(), } : {}), + ...(hasOwn(value, "chatActivity") + ? { chatActivity: parseAidenRemoteBotChatActivity(value.chatActivity) } + : {}), }; } @@ -4903,13 +5016,23 @@ export function parseAidenRemoteBotSessionEntry(value: unknown): AidenRemoteBotS case "message": { assertExactKeys( value, - ["type", "id", "role", "text", "createdAt", "label", "interrupted"], + ["type", "id", "role", "text", "createdAt", "label", "interrupted", "submissionId"], "Bot session message", ); const role = enumMember(value.role, ["user", "assistant"] as const, "Bot session message role"); if (hasOwn(value, "interrupted") && (value.interrupted !== true || role !== "assistant")) { throw new Error("Only an assistant message can be marked interrupted."); } + // A routine's input (it has a label) is never the person's message. + if (hasOwn(value, "submissionId") && (role !== "user" || hasOwn(value, "label"))) { + throw new Error("Only the person's message can carry a submissionId."); + } + const submissionId = hasOwn(value, "submissionId") + ? boundedText(value.submissionId, "Bot session message submissionId", 64) + : undefined; + if (submissionId !== undefined && !AIDEN_REMOTE_BOT_SUBMISSION_ID.test(submissionId)) { + throw new Error("Bot session message submissionId is invalid."); + } return { type: "message", id, @@ -4922,6 +5045,7 @@ export function parseAidenRemoteBotSessionEntry(value: unknown): AidenRemoteBotS ? { label: boundedText(value.label, "Bot session message label", 120) } : {}), ...(hasOwn(value, "interrupted") ? { interrupted: true as const } : {}), + ...(submissionId !== undefined ? { submissionId } : {}), }; } case "connect_card": @@ -5195,11 +5319,50 @@ function boundedEpoch(value: unknown, label: string): string { return epoch; } +/** The §11 allow-list: phase, app and startedAt, and nothing from the step. */ +export function parseAidenRemoteBotSessionActivity(value: unknown): AidenRemoteBotSessionActivity { + if (!isRecord(value)) throw new Error("Bot session activity must be an object."); + assertExactKeys(value, ["phase", "app", "startedAt"], "Bot session activity"); + const phase = boundedText(value.phase, "Bot session activity phase", 32); + if (!AIDEN_REMOTE_BOT_ACTIVITY_PHASE.test(phase)) throw new Error("Bot session activity phase is invalid."); + return { + phase, + ...(hasOwn(value, "app") + ? { app: boundedText(value.app, "Bot session activity app", AIDEN_REMOTE_BOT_ACTIVITY_APP_MAX_CHARS) } + : {}), + startedAt: boundedIntegerValue(value.startedAt, "Bot session activity startedAt", 0, Number.MAX_SAFE_INTEGER), + }; +} + +export function parseAidenRemoteBotQueuedMessage(value: unknown): AidenRemoteBotQueuedMessage { + if (!isRecord(value)) throw new Error("Bot queued message must be an object."); + assertExactKeys(value, ["submissionId", "mode", "text"], "Bot queued message"); + const submissionId = boundedText(value.submissionId, "Bot queued message submissionId", 64); + if (!AIDEN_REMOTE_BOT_SUBMISSION_ID.test(submissionId)) throw new Error("Bot queued message submissionId is invalid."); + return { + submissionId, + mode: enumMember(value.mode, AIDEN_REMOTE_BOT_WHEN_BUSY_MODES, "Bot queued message mode"), + text: boundedText(value.text, "Bot queued message text", AIDEN_REMOTE_BOT_MESSAGE_MAX_CHARS), + }; +} + +function parseBotQueuedList(value: unknown): AidenRemoteBotQueuedMessage[] { + if (!Array.isArray(value)) throw new Error("Bot queued messages must be an array."); + if (value.length > AIDEN_REMOTE_BOT_QUEUED_MAX) { + throw new Error(`Bot queued messages must contain at most ${AIDEN_REMOTE_BOT_QUEUED_MAX} items.`); + } + const queued = value.map(parseAidenRemoteBotQueuedMessage); + if (new Set(queued.map((message) => message.submissionId)).size !== queued.length) { + throw new Error("Bot queued message ids must be unique."); + } + return queued; +} + export function parseAidenRemoteBotSession(value: unknown): AidenRemoteBotSession { if (!isRecord(value)) throw new Error("Bot session must be an object."); assertExactKeys( value, - ["botId", "epoch", "seq", "state", "interrupted", "blocked", "partial", "entries", "hasOlder", "question", "approval"], + ["botId", "epoch", "seq", "state", "interrupted", "blocked", "partial", "entries", "hasOlder", "question", "approval", "activity", "queued"], "Bot session", ); if (!hasOwn(value, "question")) throw new Error("Bot session question is required."); @@ -5223,6 +5386,10 @@ export function parseAidenRemoteBotSession(value: unknown): AidenRemoteBotSessio hasOlder: requiredBooleanValue(value.hasOlder, "Bot session hasOlder"), question: value.question === null ? null : parseAidenRemoteBotQuestion(value.question), approval: value.approval === null ? null : parseAidenRemoteBotApproval(value.approval), + ...(hasOwn(value, "activity") + ? { activity: value.activity === null ? null : parseAidenRemoteBotSessionActivity(value.activity) } + : {}), + ...(hasOwn(value, "queued") ? { queued: parseBotQueuedList(value.queued) } : {}), }; } @@ -5276,18 +5443,37 @@ export function parseAidenRemoteBotSessionEvent(value: unknown): AidenRemoteBotS type, payload: { approval: payload.approval === null ? null : parseAidenRemoteBotApproval(payload.approval) }, }; + case "activity": + assertExactKeys(payload, ["activity"], "Bot session activity event"); + if (!hasOwn(payload, "activity")) throw new Error("Bot session activity event activity is required."); + return { + ...base, + type, + payload: { activity: payload.activity === null ? null : parseAidenRemoteBotSessionActivity(payload.activity) }, + }; + case "queued": + assertExactKeys(payload, ["queued"], "Bot session queued event"); + return { ...base, type, payload: { queued: parseBotQueuedList(payload.queued) } }; case "closed": assertExactKeys(payload, [], "Bot session closed event"); return { ...base, type, payload: {} }; } } -export function parseAidenRemoteBotMessageRequest(value: unknown): AidenRemoteBotMessageRequest { +export function parseAidenRemoteBotMessageRequest( + value: unknown, + options: { allowWhenBusy?: boolean } = {}, +): AidenRemoteBotMessageRequest { if (!isRecord(value)) throw new Error("Bot message request must be an object."); - assertExactKeys(value, ["text"], "Bot message request"); + assertExactKeys(value, options.allowWhenBusy === true ? ["text", "whenBusy"] : ["text"], "Bot message request"); const text = boundedText(value.text, "Bot message text", AIDEN_REMOTE_BOT_MESSAGE_MAX_CHARS); if (text.trim().length === 0) throw new Error("Bot message text must not be blank."); - return { text }; + return { + text, + ...(hasOwn(value, "whenBusy") + ? { whenBusy: enumMember(value.whenBusy, AIDEN_REMOTE_BOT_WHEN_BUSY_MODES, "Bot message whenBusy") } + : {}), + }; } const BOT_QUESTION_WAIT_ID = /^[A-Za-z0-9-]{1,64}$/u; @@ -5531,6 +5717,54 @@ function requestResponseRecord(value: unknown, label: string): { request: unknow return { request: value.request, response: value.response }; } +export interface AidenRemoteBotActivityFixture { + /** §5 in order: every phase id with its word and mark. */ + phases: Array<{ id: string; word: string; mark: string }>; + /** Phases that keep their word under Always "Thinking". */ + thinkingKeeps: string[]; + /** "Using {app}" with a sample label. */ + app: { app: string; word: string }; + /** Elapsed labels, including a phone whose clock is behind the Mac's. */ + elapsed: Array<{ startedAt: number; now: number; label: string | null }>; + session: AidenRemoteBotSession; + events: AidenRemoteBotSessionEvent[]; + /** A queued message placed in the turn: its bubble carries the same `submissionId`. */ + entryEvent: AidenRemoteBotSessionEvent; + detail: AidenRemoteBotDetail; + identityPatch: { request: AidenRemoteBotIdentityPatchRequest; response: AidenRemoteBotDetail }; + send: { request: AidenRemoteBotMessageRequest; response: AidenRemoteBotMessageReceipt }; +} + +function parseBotActivityFixture(value: unknown): AidenRemoteBotActivityFixture { + if (!isRecord(value)) throw new Error("Bot activity fixture must be an object."); + assertExactKeys( + value, + ["phases", "thinkingKeeps", "app", "elapsed", "session", "events", "entryEvent", "detail", "identityPatch", "send"], + "Bot activity fixture", + ); + if (!Array.isArray(value.events)) throw new Error("Bot activity fixture events must be an array."); + const identity = requestResponseRecord(value.identityPatch, "Bot activity identity patch"); + const send = requestResponseRecord(value.send, "Bot activity send"); + return { + phases: value.phases as AidenRemoteBotActivityFixture["phases"], + thinkingKeeps: value.thinkingKeeps as string[], + app: value.app as AidenRemoteBotActivityFixture["app"], + elapsed: value.elapsed as AidenRemoteBotActivityFixture["elapsed"], + session: parseAidenRemoteBotSession(value.session), + events: value.events.map(parseAidenRemoteBotSessionEvent), + entryEvent: parseAidenRemoteBotSessionEvent(value.entryEvent), + detail: parseAidenRemoteBotDetail(value.detail), + identityPatch: { + request: parseAidenRemoteBotIdentityPatchRequest(identity.request, { allowChatActivity: true }), + response: parseAidenRemoteBotDetail(identity.response), + }, + send: { + request: parseAidenRemoteBotMessageRequest(send.request, { allowWhenBusy: true }), + response: parseAidenRemoteBotMessageReceipt(send.response), + }, + }; +} + export function parseAidenRemoteContractFixture(value: unknown): AidenRemoteContractFixture { if (!isRecord(value)) throw new Error("Aiden Remote contract fixture must be an object."); assertNoPrivateBotWireFields(value); @@ -6303,6 +6537,11 @@ export function parseAidenRemoteContractFixture(value: unknown): AidenRemoteCont if (chat.messages.some((message) => message.visuals?.length) && contractRevision < 28) { throw new Error("Chat visuals fixtures require contract revision 28."); } + let botActivity: AidenRemoteBotActivityFixture | undefined; + if (value.botActivity !== undefined) { + if (contractRevision < 30) throw new Error("Bot activity fixtures require contract revision 30."); + botActivity = parseBotActivityFixture(value.botActivity); + } assertNoForbiddenWireKeys(value); return { ...value, @@ -6383,6 +6622,7 @@ export function parseAidenRemoteContractFixture(value: unknown): AidenRemoteCont events, error, ...revision19, + ...(botActivity ? { botActivity } : {}), }; } diff --git a/main/services/aiden-remote-router.test.ts b/main/services/aiden-remote-router.test.ts index 5863a87bd..85ba9c703 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -22,6 +22,10 @@ import { type AidenRemoteStreamInputResult, } from "./aiden-remote-protocol.js"; import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import { AidenRemoteBotSessionService } from "./aiden-remote-bot-session.js"; +import { AidenRemoteBotService } from "./aiden-remote-bots.js"; +import { BotSessionError, type BotSessionState } from "./bot-runtime/bot-session-service.js"; +import { createBotTurnIntents } from "./bot-runtime/bot-turn-intents.js"; import { AidenRemoteHostFeedService } from "./aiden-remote-host-feed.js"; import { AidenRemoteHostRunService } from "./aiden-remote-host-runs.js"; import { HostRunRegistry } from "./host-run-registry.js"; @@ -67,6 +71,8 @@ async function fixture(options: { /** "plain" forks; "summary" also forks with a summary. */ forks?: "plain" | "summary"; platform?: "mac" | "linux" | "windows"; + /** The Bot service serves and accepts Chat activity (contract revision 30). */ + chatActivity?: boolean; } = {}) { const logs: unknown[] = []; const calls: string[] = []; @@ -114,6 +120,10 @@ async function fixture(options: { policyEpoch: "bot_policy_epoch_1", summary: "Can use your Mac, shell, enabled connections, and skills.", }; + // A Mac wired for Chat activity returns the field on every Bot detail it serves. + const detailChatActivity = options.chatActivity + ? { chatActivity: { level: "summary" as const, wording: "follows" as const } } + : {}; const botDetail = { id: "bot-1", name: "Planner", @@ -210,6 +220,7 @@ async function fixture(options: { capability !== "host:events" && capability !== "runs:observe" && capability !== "runs:control" && + capability !== "bot:activity" && capability !== "bot:cards", ) ) { @@ -503,21 +514,28 @@ async function fixture(options: { }), }, bots: { + ...(options.chatActivity ? { supportsChatActivity: true } : {}), list: async () => { calls.push("bots:list"); - return { bots: [botDetail], maxBots: 256 }; + const { instructions: _instructions, access: _access, ...summary } = botDetail; + return { bots: [summary], maxBots: 256 }; }, get: async (botId) => { calls.push(`bots:get:${botId}`); - return { ...botDetail, id: botId, access: { ...botAccess, botId } }; + return { + ...botDetail, + id: botId, + access: { ...botAccess, botId }, + ...detailChatActivity, + }; }, create: async (deviceId, key) => { calls.push(`bots:create:${deviceId}:${key}`); - return botDetail; + return { ...botDetail, ...detailChatActivity }; }, updateIdentity: async (botId, revision) => { calls.push(`bots:update:${botId}:${revision}`); - return { ...botDetail, id: botId, revision: "bot_revision_2" }; + return { ...botDetail, id: botId, revision: "bot_revision_2", ...detailChatActivity }; }, delete: async (botId, revision) => { calls.push(`bots:delete:${botId}:${revision}`); @@ -575,7 +593,7 @@ async function fixture(options: { }, deleteAvatar: async (botId, revision) => { calls.push(`bots:avatar:delete:${botId}:${revision}`); - return { ...botDetail, id: botId }; + return { ...botDetail, id: botId, ...detailChatActivity }; }, avatarContent: async (botId, assetRevision) => { calls.push(`bots:avatar:content:${botId}:${assetRevision}`); @@ -4187,7 +4205,7 @@ test("the opt-in health descriptor identifies the host; the default body is unch instanceId: "instance-1", displayName: "Studio Mac", platform: "mac", - contractRevision: 29, + contractRevision: 30, // No request service is wired in this fixture, so requests are off. pairingRequests: false, }); @@ -5058,3 +5076,280 @@ test("the Bot question answer route needs Bot write access", async () => { await app.close(); } }); + +function recordingActivitySessions(seen: string[], supportsActivity: boolean) { + const base = recordingRevision27Sessions([], { memory: false, proactive: false }) as unknown as Record; + return { + ...base, + supportsActivity, + session: async (botId: string, options: { activity?: boolean } = {}) => { + seen.push(`session:${options.activity === true ? "activity" : "plain"}`); + return { botId, epoch: "epoch_1", seq: 0, state: "idle", interrupted: false, entries: [], hasOlder: false }; + }, + openEvents: async ( + _deviceId: string, + _botId: string, + response: import("node:http").ServerResponse, + admit: () => void, + options: { activity?: boolean } = {}, + ) => { + seen.push(`events:${options.activity === true ? "activity" : "plain"}`); + admit(); + response.writeHead(204); + response.end(); + }, + } as unknown as NonNullable; +} + +test("bot-activity-v1 and bot:activity are offered only while activity frames and chatActivity are both wired", async () => { + const headers = { authorization: `Bearer ${"a".repeat(43)}`, "aiden-protocol-version": "1" }; + const negotiate = { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ accepts: ["bot:activity"] }) }; + for (const [sessionsActivity, chatActivity, wired] of [ + [true, true, true], + [true, false, false], + [false, true, false], + ] as const) { + const app = await fixture({ + capabilities: ["server:read", "bot:read", "chat:read"], + acceptsBotCapabilities: true, + deviceType: "iphone", + botSessions: recordingActivitySessions([], sessionsActivity), + chatActivity, + }); + try { + const server = await (await fetch(`${app.base}/server`, { headers })).json(); + assert.equal(server.features.includes("bot-activity-v1"), wired, `${sessionsActivity}/${chatActivity}`); + assert.equal(server.serverCapabilities.includes("bot:activity"), wired); + const response = await fetch(`${app.base}/device/capabilities`, negotiate); + assert.equal(response.status, wired ? 200 : 404); + if (!wired) { + assert.equal((await response.json()).error?.code, "not_found", "never the misleading credential_revoked"); + } + } finally { + await app.close(); + } + + // A phone that already holds the grant (paired while this Mac was fully wired) is + // served activity on its session and stream only while both halves are wired now. + const seen: string[] = []; + const holder = await fixture({ + capabilities: ["server:read", "bot:read", "chat:read", "bot:activity"], + acceptsBotCapabilities: true, + deviceType: "iphone", + botSessions: recordingActivitySessions(seen, sessionsActivity), + chatActivity, + }); + try { + assert.equal((await fetch(`${holder.base}/bots/bot-1/session`, { headers })).status, 200); + assert.equal((await fetch(`${holder.base}/bots/bot-1/session/events`, { headers })).status, 204); + const served = wired ? "activity" : "plain"; + assert.deepEqual(seen, [`session:${served}`, `events:${served}`], `${sessionsActivity}/${chatActivity}`); + } finally { + await holder.close(); + } + } +}); + +test("only a device holding bot:activity is served activity frames and a Bot's chatActivity", async () => { + const headers = { authorization: `Bearer ${"a".repeat(43)}`, "aiden-protocol-version": "1" }; + const seen: string[] = []; + const write = { ...headers, "content-type": "application/json", "if-match": "bot_revision_1" }; + // Every route that returns a Bot detail, keyed by name. + const detailRoutes = (base: string): Array<[string, () => Promise]> => [ + ["get", () => fetch(`${base}/bots/bot-1`, { headers })], + ["create", () => fetch(`${base}/bots`, { + method: "POST", + headers: { ...write, "idempotency-key": "bot-create-key-0000001" }, + body: JSON.stringify({ name: "Planner" }), + })], + ["patch", () => fetch(`${base}/bots/bot-1`, { method: "PATCH", headers: write, body: JSON.stringify({ name: "Planner" }) })], + ["avatar delete", () => fetch(`${base}/bots/bot-1/avatar`, { method: "DELETE", headers: write })], + ]; + const shown: Record = { plain: [], holder: [] }; + for (const [label, capabilities] of [ + ["plain", ["server:read", "bot:read", "bot:write", "chat:read"]], + ["holder", ["server:read", "bot:read", "bot:write", "chat:read", "bot:activity"]], + ] as Array<[string, AidenRemoteCapability[]]>) { + const app = await fixture({ + capabilities, + acceptsBotCapabilities: true, + deviceType: "iphone", + botSessions: recordingActivitySessions(seen, true), + chatActivity: true, + }); + try { + await fetch(`${app.base}/bots/bot-1/session`, { headers }); + await fetch(`${app.base}/bots/bot-1/session/events`, { headers }); + for (const [route, call] of detailRoutes(app.base)) { + const response = await call(); + assert.ok(response.status === 200 || response.status === 201, `${label} ${route}: ${response.status}`); + const detail = await response.json(); + shown[label]!.push(`${route}:${"chatActivity" in detail ? "activity" : "plain"}`); + } + } finally { + await app.close(); + } + } + assert.deepEqual(seen, ["session:plain", "events:plain", "session:activity", "events:activity"]); + assert.deepEqual(shown.plain, ["get:plain", "create:plain", "patch:plain", "avatar delete:plain"]); + assert.deepEqual(shown.holder, ["get:activity", "create:activity", "patch:activity", "avatar delete:activity"]); +}); + +test("a bubble's submissionId reaches only a device holding bot:activity, on a fetch and on the stream's snapshot", async () => { + const headers = { authorization: `Bearer ${"a".repeat(43)}`, "aiden-protocol-version": "1" }; + const placedMessage = { + id: 1, + kind: "pi.user", + model: [{ role: "user", content: [{ type: "text", text: "Plan dinners" }], timestamp: 1_700_000_000_000 }], + }; + const sessions = new AidenRemoteBotSessionService({ + bots: { + supportsChatActivity: true, + bot: async (botId) => ({ + id: botId, revision: "bot_revision_1", name: "Planner", instructions: "Plan.", + avatar: { version: 1, shape: "orb", color: "sky" }, createdAt: 1_000, updatedAt: 2_000, + }), + summaryOf: async () => { throw new Error("not used"); }, + executeIdempotent: async (_scope, _input, action) => action(), + }, + runtime: async () => ({ + send: async () => ({ submissionId: "17", deduped: false }), + resume: async () => ({ kind: "running", submissionId: "17" }), + dismiss: async () => ({ kind: "idle" }), + stop: async () => ({ kind: "idle" }), + state: async () => ({ kind: "running", submissionId: "17" }), + conversation: async () => ({ + watch: async () => ({ + value: { entries: [placedMessage], docs: {} }, + start() {}, + stop: async () => {}, + closed: new Promise(() => {}), + }), + }) as never, + quietInputs: async () => new Map(), + placedSubmissions: async () => new Map([["1", "17"]]), + }), + turnIntents: createBotTurnIntents(), + }); + const seen: Record> = {}; + try { + for (const [label, capabilities] of [ + ["plain", ["server:read", "bot:read", "chat:read"]], + ["holder", ["server:read", "bot:read", "chat:read", "bot:activity"]], + ] as Array<[string, AidenRemoteCapability[]]>) { + const app = await fixture({ capabilities, acceptsBotCapabilities: true, deviceType: "iphone", botSessions: sessions, chatActivity: true }); + try { + const fetched = await (await fetch(`${app.base}/bots/bot-1/session`, { headers })).json(); + const controller = new AbortController(); + const stream = await fetch(`${app.base}/bots/bot-1/session/events`, { headers, signal: controller.signal }); + const reader = stream.body!.getReader(); + let text = ""; + while (!text.includes("\n\n")) text += new TextDecoder().decode((await reader.read()).value); + controller.abort(); + const snapshot = JSON.parse(text.split("\n").find((line) => line.startsWith("data: "))!.slice(6)); + assert.equal(snapshot.type, "snapshot"); + seen[label] = [fetched.entries[0].submissionId ?? null, snapshot.payload.session.entries[0].submissionId ?? null]; + if (label === "plain") assert.equal(JSON.stringify([fetched, snapshot]).includes("submissionId"), false); + } finally { + await app.close(); + } + } + } finally { + await sessions.close(); + } + assert.deepEqual(seen, { plain: [null, null], holder: ["17", "17"] }); +}); + +test("a send the paused Bot refused is not recorded: once resumed, the same key sends, and a real failure still replays", async () => { + const headers = { authorization: `Bearer ${"a".repeat(43)}`, "aiden-protocol-version": "1" }; + const post = (key: string, body = "{}") => ({ + method: "POST", + headers: { ...headers, "content-type": "application/json", "idempotency-key": key }, + body, + }); + const planner = { + id: "bot-1", revision: "bot_revision_1", name: "Planner", instructions: "Plan.", + avatar: { version: 1 as const, shape: "orb" as const, color: "sky" as const }, createdAt: 1_000, updatedAt: 2_000, + }; + let persisted: { entries: unknown[] } | undefined; + const bots = new AidenRemoteBotService({ + application: { get: async (botId: string) => (botId === "bot-1" ? planner : null), list: async () => [planner] } as never, + chatStore: { get: async () => null }, + sessionStates: async () => new Map(), + // The durable path: the ledger is written before and after every action. + persistIdempotency: async (snapshot) => { + persisted = snapshot as { entries: unknown[] }; + }, + }); + let modelBroken = true; + let paused = true; + const sent: string[] = []; + let state: BotSessionState = { kind: "interrupted", submissionId: "16" }; + const sessions = new AidenRemoteBotSessionService({ + bots, + runtime: async () => ({ + send: async (_botId, input) => { + // The session's own refusals from state, before anything is written. + if (modelBroken) throw new BotSessionError("model_error"); + if (paused) throw new BotSessionError("bot_paused"); + sent.push(input.requestId); + if (input.text === "explode") throw new Error("The input was written, then the session failed."); + state = { kind: "running", submissionId: "17" }; + return { submissionId: "17", deduped: false }; + }, + resume: async () => { + paused = false; + state = { kind: "running", submissionId: "16" }; + return state; + }, + dismiss: async () => ({ kind: "idle" }), + stop: async () => ({ kind: "idle" }), + state: async () => state, + conversation: async () => { throw new Error("not used"); }, + quietInputs: async () => new Map(), + placedSubmissions: async () => new Map(), + }), + }); + const app = await fixture({ + capabilities: ["server:read", "bot:read", "bot:write", "chat:read", "chat:write"], + acceptsBotCapabilities: true, + botSessions: sessions, + }); + try { + const message = JSON.stringify({ text: "Plan dinners" }); + // A model that can't run right now is a refusal from state too, not a server failure. + const unusable = await fetch(`${app.base}/bots/bot-1/messages`, post("message-key-000000001", message)); + assert.equal(unusable.status, 409); + assert.equal((await unusable.json()).error.code, "operation_stale"); + assert.deepEqual(persisted?.entries, [], "the model refusal left nothing in the durable ledger"); + modelBroken = false; + + const refused = await fetch(`${app.base}/bots/bot-1/messages`, post("message-key-000000001", message)); + assert.equal(refused.status, 409); + assert.equal((await refused.json()).error.code, "operation_stale"); + assert.deepEqual(persisted?.entries, [], "the refusal left nothing in the durable ledger"); + + assert.equal((await fetch(`${app.base}/bots/bot-1/resume`, post("resume-key-0000000001"))).status, 200); + const accepted = await fetch(`${app.base}/bots/bot-1/messages`, post("message-key-000000001", message)); + assert.equal(accepted.status, 200); + const receipt = await accepted.json(); + assert.equal(receipt.submissionId, "17"); + // Once it ran, the key is spent: a repeat replays the receipt without sending again. + const replayed = await fetch(`${app.base}/bots/bot-1/messages`, post("message-key-000000001", message)); + assert.deepEqual(await replayed.json(), receipt); + assert.equal(sent.length, 1); + + // A failure after the session started work keeps its key: a retry replays it and never runs twice. + const exploded = JSON.stringify({ text: "explode" }); + const failed = await fetch(`${app.base}/bots/bot-1/messages`, post("message-key-000000002", exploded)); + assert.equal(failed.status, 500); + const retried = await fetch(`${app.base}/bots/bot-1/messages`, post("message-key-000000002", exploded)); + assert.equal(retried.status, 500); + assert.equal((await retried.json()).error.code, "internal_error"); + assert.equal(sent.length, 2, "the failed send ran once"); + assert.equal(persisted?.entries.length, 3, "resume, the sent message and the failed one are recorded"); + } finally { + await app.close(); + await sessions.close(); + } +}); diff --git a/main/services/aiden-remote-router.ts b/main/services/aiden-remote-router.ts index 0a01aa1d4..096b4a622 100644 --- a/main/services/aiden-remote-router.ts +++ b/main/services/aiden-remote-router.ts @@ -32,6 +32,8 @@ import { AIDEN_REMOTE_BOT_ROUTINES_FEATURE, AIDEN_REMOTE_BOT_MEMORY_FEATURE, AIDEN_REMOTE_BOT_PROACTIVE_FEATURE, + AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES, + AIDEN_REMOTE_BOT_ACTIVITY_FEATURE, AIDEN_REMOTE_BOT_CARD_CAPABILITIES, AIDEN_REMOTE_CHAT_SUMMARY_MAX_CURSOR_LENGTH, AIDEN_REMOTE_CHAT_SUMMARY_MAX_LIMIT, @@ -57,6 +59,7 @@ import { parseAidenRemoteDeviceCapabilitiesUpdateRequest, parseAidenRemoteJson, parseAidenRemoteQuestionRespondRequest, + type AidenRemoteBotDetail, type AidenRemoteCapability, type AidenRemoteProgressCapability, type AidenRemoteBotConversationQuery, @@ -251,6 +254,7 @@ export interface AidenRemoteRouterDependencies { > & Partial>; /** * Durable Bot sessions, routines, connection requests and starter presets @@ -290,6 +294,7 @@ export interface AidenRemoteRouterDependencies { | "routineNotificationFeed" | "supportsMemory" | "supportsProactive" + | "supportsActivity" > >; /** @@ -1470,6 +1475,30 @@ function agentInterruptSupported(dependencies: AidenRemoteRouterDependencies): b ); } +/** Revision 30: the activity frames and the Chat activity field ship together, or not at all. */ +function botActivityWired(dependencies: AidenRemoteRouterDependencies): boolean { + return dependencies.botSessions?.supportsActivity === true && dependencies.bots?.supportsChatActivity === true; +} + +/** + * The one place a Bot detail leaves the router (create, get, PATCH, avatar + * DELETE): `chatActivity` only for `bot:activity` holders, so an older + * phone never sees the field whichever route returned the detail. + */ +function writeBotDetail( + response: ServerResponse, + status: number, + detail: AidenRemoteBotDetail, + device: AidenRemoteRouterAuthenticatedDevice, +): void { + if (device.capabilities.has("bot:activity")) { + writeJson(response, status, detail); + return; + } + const { chatActivity: _hidden, ...visible } = detail; + writeJson(response, status, visible); +} + function advertisedServerCapabilities( device: AidenRemoteRouterAuthenticatedDevice, dependencies: AidenRemoteRouterDependencies, @@ -1484,6 +1513,9 @@ function advertisedServerCapabilities( ...(device.acceptsBotCapabilities === true && dependencies.botSessions ? AIDEN_REMOTE_BOT_CARD_CAPABILITIES : []), + ...(device.acceptsBotCapabilities === true && botActivityWired(dependencies) + ? AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES + : []), ...(device.acceptsProgressCapabilities === true ? AIDEN_REMOTE_PROGRESS_CAPABILITIES.filter((capability) => progressCapabilitySupported(dependencies, capability), @@ -1828,6 +1860,8 @@ export function createAidenRemoteRequestHandler( // Bot memory and proactivity (contract revision 27). ...(dependencies.botSessions?.supportsMemory === true ? [AIDEN_REMOTE_BOT_MEMORY_FEATURE] : []), ...(dependencies.botSessions?.supportsProactive === true ? [AIDEN_REMOTE_BOT_PROACTIVE_FEATURE] : []), + // Bot chat activity on phones (contract revision 30). + ...(botActivityWired(dependencies) ? [AIDEN_REMOTE_BOT_ACTIVITY_FEATURE] : []), // The phone simulator viewer (contract revision 26); sharing consent is reported by `/simulators`. ...(!isDesktopDevice(device) && dependencies.simulators?.host("mobile") ? [AIDEN_REMOTE_MOBILE_SIMULATORS_FEATURE] @@ -1892,6 +1926,11 @@ export function createAidenRemoteRequestHandler( if (!dependencies.botSessions) { throw new AidenRemoteServiceError("not_found", "Bot sessions are unavailable on this Aiden installation.", 404); } + } else if ((AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES as readonly string[]).includes(capability)) { + // Revision 30: an opt-in to the activity frames and the detail field, offered only while they are wired. + if (!botActivityWired(dependencies)) { + throw new AidenRemoteServiceError("not_found", "Bot chat activity is unavailable on this Aiden installation.", 404); + } } else if (capability === "simulators:control") { // Refuse non-desktops first so they never learn whether this Mac has simulators. if (device.type !== "mac" && device.type !== "linux") { @@ -1976,11 +2015,7 @@ export function createAidenRemoteRequestHandler( throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); } const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); - writeJson( - response, - 201, - await dependencies.bots.create(device.id, key, body), - ); + writeBotDetail(response, 201, await dependencies.bots.create(device.id, key, body), device); return; } if (path === "/bot-capabilities" && request.method === "GET") { @@ -2112,16 +2147,11 @@ export function createAidenRemoteRequestHandler( } const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + // Returns the avatar asset metadata, not a Bot detail. writeJson( response, 200, - await dependencies.bots.putAvatar( - device.id, - botAvatarMatch[1]!, - revision, - key, - body, - ), + await dependencies.bots.putAvatar(device.id, botAvatarMatch[1]!, revision, key, body), ); return; } @@ -2135,10 +2165,11 @@ export function createAidenRemoteRequestHandler( throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); } const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); - writeJson( + writeBotDetail( response, 200, await dependencies.bots.deleteAvatar(botAvatarMatch[1]!, revision), + device, ); return; } @@ -2173,7 +2204,10 @@ export function createAidenRemoteRequestHandler( writeJson( response, 200, - await botSessionsOrNotFound(dependencies).session(botSessionMatch[1]!, { cards: device.capabilities.has("bot:cards") }), + await botSessionsOrNotFound(dependencies).session(botSessionMatch[1]!, { + cards: device.capabilities.has("bot:cards"), + activity: device.capabilities.has("bot:activity") && botActivityWired(dependencies), + }), ); return; } @@ -2190,7 +2224,7 @@ export function createAidenRemoteRequestHandler( botSessionEventsMatch[1]!, response, admitDevice(device.id), - { cards: device.capabilities.has("bot:cards") }, + { cards: device.capabilities.has("bot:cards"), activity: device.capabilities.has("bot:activity") && botActivityWired(dependencies) }, ); return; } @@ -2475,7 +2509,7 @@ export function createAidenRemoteRequestHandler( if (!dependencies.bots) { throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); } - writeJson(response, 200, await dependencies.bots.get(botMatch[1]!, device.id)); + writeBotDetail(response, 200, await dependencies.bots.get(botMatch[1]!, device.id), device); return; } if (botMatch && request.method === "PATCH") { @@ -2489,15 +2523,11 @@ export function createAidenRemoteRequestHandler( throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); } const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); - writeJson( + writeBotDetail( response, 200, - await dependencies.bots.updateIdentity( - botMatch[1]!, - revision, - body, - device.id, - ), + await dependencies.bots.updateIdentity(botMatch[1]!, revision, body, device.id), + device, ); return; } diff --git a/main/services/aiden-remote-service-main.ts b/main/services/aiden-remote-service-main.ts index b84f3fe2c..c9c6219b3 100644 --- a/main/services/aiden-remote-service-main.ts +++ b/main/services/aiden-remote-service-main.ts @@ -125,6 +125,7 @@ import { } from "./aiden-remote-bot-session.js"; import { botApprovals } from "./bot-runtime/bot-approvals-main.js"; import { botQuestions } from "./bot-runtime/bot-questions-main.js"; +import { botRunStarts, botTurnIntents } from "./bot-runtime/bot-turn-intents-main.js"; import { botMutationGate } from "./bot-mutation-gate.js"; import { botApplicationService } from "./bot-application-service-main.js"; import { @@ -852,6 +853,7 @@ async function createRuntime(): Promise { throw error; } }, + chatActivity: true, idempotency, persistIdempotency: (snapshot) => operationStore.save(snapshot), notifyBotsChanged: () => ipcMain.broadcast("bots:changed", {}), @@ -881,6 +883,10 @@ async function createRuntime(): Promise { routines: botRoutineService, questions: botQuestions, approvals: botApprovals, + turnIntents: botTurnIntents, + // The desktop feed's run start times: a phone opening mid-run sees the real elapsed time. + runStarts: botRunStarts, + onReport: (botId, error) => logger.warn("aiden-remote", `Bot ${botId} queued or placed messages could not be read for phones.`, error), presets: { list: () => BOT_PRESETS, // The one process-wide starter the desktop uses: a Mac tap and a diff --git a/main/services/aiden-remote-state.test.ts b/main/services/aiden-remote-state.test.ts index e91343f48..7db8d0f50 100644 --- a/main/services/aiden-remote-state.test.ts +++ b/main/services/aiden-remote-state.test.ts @@ -967,3 +967,41 @@ test("the phone simulator viewer grant is negotiated only by phones and never is assert.equal((await restored.registry.authenticate(mac.credential))?.capabilities.has("simulators:mobile"), false); assert.equal((await restored.registry.authenticate(phone.credential))?.capabilities.has("simulators:mobile"), true); }); + +test("bot:activity is an opt-in any paired device may negotiate and keep", async () => { + const state = fixture(); + const issued = await state.registry.issueDevice({ name: "iPhone", type: "iphone", clientVersion: "1.0" }); + const upgraded = await state.registry.upgradeDeviceCapabilities(issued.device.id, ["bot:cards", "bot:activity"]); + assert.deepEqual(upgraded?.capabilities.slice(-2), ["bot:cards", "bot:activity"]); + const authenticated = await state.registry.authenticate(issued.credential); + assert.equal(authenticated?.capabilities.has("bot:activity"), true); + assert.equal(authenticated?.acceptsProgressCapabilities, false, "an opt-in is not a progress grant"); +}); + +test("bot:activity survives a restart, dies with revocation, and an older stored state simply lacks it", async () => { + const state = fixture(); + const issued = await state.registry.issueDevice({ name: "iPhone", type: "iphone", clientVersion: "1.0" }); + await state.registry.upgradeDeviceCapabilities(issued.device.id, ["bot:activity"]); + + // The grant is persisted: a restored registry still honours it. + const restored = fixture(state.stored()); + await restored.registry.initialize(); + assert.equal((await restored.registry.authenticate(issued.credential))?.capabilities.has("bot:activity"), true); + + // Revoking ends the credential; pairing again starts without the opt-in until it is negotiated anew. + assert.equal(await state.registry.revokeDevice(issued.device.id), true); + assert.equal((await state.registry.authenticate(issued.credential))?.revoked, true); + const repaired = await state.registry.issueDevice({ name: "iPhone", type: "iphone", clientVersion: "1.0" }); + assert.equal((await state.registry.authenticate(repaired.credential))?.capabilities.has("bot:activity"), false); + await state.registry.upgradeDeviceCapabilities(repaired.device.id, ["bot:activity"]); + assert.equal((await state.registry.authenticate(repaired.credential))?.capabilities.has("bot:activity"), true); + + // A state written before revision 30 has no such grant, loads cleanly, and can still negotiate it. + const older = fixture(); + const legacy = await older.registry.issueDevice({ name: "iPhone", type: "iphone", clientVersion: "1.0" }); + const reloaded = fixture(older.stored()); + await reloaded.registry.initialize(); + assert.equal((await reloaded.registry.authenticate(legacy.credential))?.capabilities.has("bot:activity"), false); + await reloaded.registry.upgradeDeviceCapabilities(legacy.device.id, ["bot:activity"]); + assert.equal((await reloaded.registry.authenticate(legacy.credential))?.capabilities.has("bot:activity"), true); +}); diff --git a/main/services/aiden-remote-state.ts b/main/services/aiden-remote-state.ts index 6ca22cfec..3550f0094 100644 --- a/main/services/aiden-remote-state.ts +++ b/main/services/aiden-remote-state.ts @@ -15,7 +15,7 @@ import { AIDEN_REMOTE_HOST_CAPABILITIES, AIDEN_REMOTE_PHONE_RUN_CAPABILITIES, AIDEN_REMOTE_MOBILE_SIMULATOR_CAPABILITIES, - AIDEN_REMOTE_BOT_CARD_CAPABILITIES, + AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES, } from "./aiden-remote-protocol.js"; import { AIDEN_REMOTE_DEVELOPMENT_LAN_PORT, @@ -259,13 +259,13 @@ function isMobileSimulatorCapability( ); } -/** Revision 27 opt-in to Bot session cards: no authority, any device type. */ -function isBotCardCapability( +/** Revision 27/30 opt-ins (`bot:cards`, `bot:activity`): no authority, any device type. */ +function isBotOptInCapability( value: unknown, -): value is (typeof AIDEN_REMOTE_BOT_CARD_CAPABILITIES)[number] { +): value is (typeof AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES)[number] { return ( typeof value === "string" && - (AIDEN_REMOTE_BOT_CARD_CAPABILITIES as readonly string[]).includes(value) + (AIDEN_REMOTE_BOT_OPT_IN_CAPABILITIES as readonly string[]).includes(value) ); } @@ -806,7 +806,7 @@ export class AidenRemoteStateRegistry { !isSimulatorCapability(capability) && !isMobileSimulatorCapability(capability) && !isHostCapability(capability) && - !isBotCardCapability(capability)) + !isBotOptInCapability(capability)) ) { return null; } @@ -836,7 +836,7 @@ export class AidenRemoteStateRegistry { isSimulatorCapability(capability) || isMobileSimulatorCapability(capability) || isHostCapability(capability) || - isBotCardCapability(capability)) && + isBotOptInCapability(capability)) && !granted.has(capability as AidenRemoteCapability), ); const acceptsProgress = accepts.some(isProgressCapability); diff --git a/main/services/bot-runtime/bot-session-main.ts b/main/services/bot-runtime/bot-session-main.ts index 1b6de0ebe..b4faf9211 100644 --- a/main/services/bot-runtime/bot-session-main.ts +++ b/main/services/bot-runtime/bot-session-main.ts @@ -43,6 +43,8 @@ import { telegramBotBindingAuthority, telegramBotBindings } from "../telegram/te import { botApprovals } from "./bot-approvals-main.js"; import { wireBotAttention } from "./bot-attention.js"; import { botQuestions } from "./bot-questions-main.js"; +import { wireBotLiveTurnIntents } from "./bot-turn-intents.js"; +import { botRunStarts, botTurnIntents } from "./bot-turn-intents-main.js"; import type { BotExtensionDeps } from "./bot-extension.js"; import { createBotRuntimeModels, type BotModelRuntime } from "./bot-models.js"; import { @@ -342,6 +344,12 @@ wireBotAttention({ broadcast: (channel, payload) => ipcMain.broadcast(channel, payload), }); +// A Stop from any surface (the Mac or a phone), a Stop that failed, and a +// Resume the session carried out reach open desktop chats as "Stopping", +// back to the work, and "Picking up where it left off". Remote projectors +// listen to the same emitter (`aiden-remote-service-main.ts`). +wireBotLiveTurnIntents({ intents: botTurnIntents, projection: botLiveProjection }); + /** The process-wide live projection that renderer windows subscribe to. */ export function botLiveProjection(): BotLiveProjection { liveProjection ??= createBotLiveProjection({ @@ -351,6 +359,7 @@ export function botLiveProjection(): BotLiveProjection { question: (botId) => botQuestions.pending(botId)[0] ?? null, approvalPending: (botId) => botApprovals.pending(botId).length > 0, connectCardStatus, + runStarts: botRunStarts, onRunSettled: broadcastState, onError: (botId, error) => logger.warn("bots", `Bot ${botId} live view report.`, error), }); @@ -392,7 +401,8 @@ export function botSessionRuntime(): Promise { unbindTelegram, eraseBotData, ], - onResumed: (botId) => botLiveProjection().notifyResuming(botId), + // Announced only here, after the session resumed, never by a route ahead of `resume`. + onResumed: (botId) => botTurnIntents.announce(botId, "resuming"), onStateChange: (botId, state) => { broadcastState(botId, state); botLiveProjection().notifyState(botId, state); diff --git a/main/services/bot-runtime/bot-session-service.ts b/main/services/bot-runtime/bot-session-service.ts index f6471fe89..fd8577c19 100644 --- a/main/services/bot-runtime/bot-session-service.ts +++ b/main/services/bot-runtime/bot-session-service.ts @@ -175,6 +175,14 @@ export interface BotSessionRuntime extends BotSessionService { * known from its durable request id). Ids it cannot find are left out. */ quietInputs(botId: string, submissionIds: readonly string[]): Promise>; + /** + * The person's own inputs the Bot is working on now, keyed by the `pi.user` + * entry each one was placed as (entry id → submission id). Placement is + * atomic with that entry's append, so an entry a watch shows is already + * here; once its turn ends the submission is done and drops out. Routine + * inputs and the hidden self-intro are never listed (`quietInputs`). + */ + placedSubmissions(botId: string): Promise>; /** * Compact the Bot's one conversation now (Telegram `/new`), focused on what * the person shared and what the Bot promised (`compactionInstructions`). @@ -323,6 +331,15 @@ export async function createBotSessionService(deps: BotSessionServiceDeps): Prom }); } + /** Not the person's own message: a routine's input or the hidden self-intro, known from its durable request id. */ + function isQuietSubmission(record: SubmissionRecord): boolean { + const requestId = record.requestId ?? ""; + // `hiddenSubmissions` is latent: the only hidden sender (the self-intro) already has the + // `intro:` prefix. Any future hidden sender needs a durable request-id prefix, because this + // set is lost on restart. + return requestId.startsWith("routine:") || requestId.startsWith(BOT_INTRO_REQUEST_PREFIX) || hiddenSubmissions.has(String(record.id)); + } + function requireHost(): BotHarnessHost { if (host === null) throw new BotSessionError("unavailable"); return host; @@ -443,19 +460,22 @@ export async function createBotSessionService(deps: BotSessionServiceDeps): Prom const wanted = new Set(submissionIds); for (const record of inspection.submissions) { const id = String(record.id); - if (!wanted.has(id)) continue; - const requestId = record.requestId ?? ""; - // `hiddenSubmissions` is latent: the only hidden sender (the self-intro) already has the - // `intro:` prefix. Any future hidden sender needs a durable request-id prefix, because this - // set is lost on restart. - answers.set( - id, - requestId.startsWith("routine:") || requestId.startsWith(BOT_INTRO_REQUEST_PREFIX) || hiddenSubmissions.has(id), - ); + if (wanted.has(id)) answers.set(id, isQuietSubmission(record)); } return answers; }, + async placedSubmissions(botId) { + const placed = new Map(); + if (deleted.has(botId)) return placed; + const inspection = await requireHost().inspect(botId); + for (const record of inspection.submissions) { + if (record.type !== "input" || record.entry === undefined || isQuietSubmission(record)) continue; + placed.set(String(record.entry), String(record.id)); + } + return placed; + }, + state(botId) { return currentState(botId); }, diff --git a/main/services/bot-runtime/bot-turn-intents-main.ts b/main/services/bot-runtime/bot-turn-intents-main.ts new file mode 100644 index 000000000..863ffabd0 --- /dev/null +++ b/main/services/bot-runtime/bot-turn-intents-main.ts @@ -0,0 +1,17 @@ +// The process-wide Bot turn intents. Desktop Stop (`main/handlers/bots.ts`), +// Remote Stop (`aiden-remote-bot-session.ts`) and the session's Resume +// (`onResumed` in `bot-session-main.ts`) announce here; the desktop live +// projection (`bot-session-main.ts`) and Remote projectors listen. +// `botRunStarts` is the run start times those same surfaces share. + +import { logger } from "../../platform.js"; +import { createBotTurnIntents } from "./bot-turn-intents.js"; +import { createBotRunStarts } from "./live-projection.js"; + +/** When each Bot's run started, shared by the desktop feed and every Remote projector. */ +export const botRunStarts = createBotRunStarts(); + +export const botTurnIntents = createBotTurnIntents({ + // A surface that missed a Stop or Resume would keep its old line; say so instead of hiding it. + onError: (botId, intent, error) => logger.warn("bots", `Bot ${botId} ${intent} listener failed.`, error), +}); diff --git a/main/services/bot-runtime/bot-turn-intents.ts b/main/services/bot-runtime/bot-turn-intents.ts new file mode 100644 index 000000000..910362759 --- /dev/null +++ b/main/services/bot-runtime/bot-turn-intents.ts @@ -0,0 +1,87 @@ +// Stop and Resume, shared by every surface that shows a Bot's activity. The +// desktop live projection and the Remote session projectors turn these into +// "Stopping" and "Picking up where it left off" (spec §5), so a Stop pressed +// on the Mac or on a phone reads the same everywhere. +// +// - `stopping`: a person pressed Stop. Listeners ignore it when nothing runs. +// - `stopCancelled`: that Stop failed, so the run goes on and stops reading "Stopping". +// - `resuming`: the session resumed an interrupted turn. Only the session +// announces it (`onResumed`, inside its per-Bot serialization, and only when +// that call resumed), never a route ahead of `resume`, so a Resume that did +// nothing never leaves a stale "Picking up where it left off". + +import type { BotLiveProjection } from "./live-projection.js"; + +export type BotTurnIntent = "stopping" | "stopCancelled" | "resuming"; + +export interface BotTurnIntents { + /** + * Tell every listener, synchronously and in order. A Resume relies on that: + * it must be heard before the session publishes the resumed run's state. + */ + announce(botId: string, intent: BotTurnIntent): void; + onIntent(listener: (botId: string, intent: BotTurnIntent) => void): () => void; +} + +export interface BotTurnIntentsDeps { + /** A listener threw: its surface missed this intent. The others still heard it. */ + onError?(botId: string, intent: BotTurnIntent, error: unknown): void; +} + +export function createBotTurnIntents(deps: BotTurnIntentsDeps = {}): BotTurnIntents { + const listeners = new Set<(botId: string, intent: BotTurnIntent) => void>(); + return { + announce(botId, intent) { + for (const listener of [...listeners]) { + try { + listener(botId, intent); + } catch (error) { + // A status line that can't update must never block the Stop itself, or the next listener. + try { + deps.onError?.(botId, intent, error); + } catch { + // A failing report must not block delivery either. + } + } + } + }, + onIntent(listener) { + listeners.add(listener); + return () => void listeners.delete(listener); + }, + }; +} + +/** + * Stop, as every surface presses it: "Stopping" shows at once, and a Stop + * that fails takes it back before the error reaches the caller. + */ +export async function stopWithIntents( + intents: Pick, + botId: string, + stop: () => Promise, +): Promise { + intents.announce(botId, "stopping"); + try { + return await stop(); + } catch (error) { + intents.announce(botId, "stopCancelled"); + throw error; + } +} + +interface BotLiveTurnIntentsDeps { + intents: Pick; + /** The desktop live projection, read per intent (it is created on first use). */ + projection(): Pick; +} + +/** Open desktop chats follow Stop and Resume from any surface; the returned function stops it. */ +export function wireBotLiveTurnIntents(deps: BotLiveTurnIntentsDeps): () => void { + return deps.intents.onIntent((botId, intent) => { + const projection = deps.projection(); + if (intent === "stopping") projection.notifyStopping(botId); + else if (intent === "stopCancelled") projection.cancelStopping(botId); + else projection.notifyResuming(botId); + }); +} diff --git a/main/services/bot-runtime/live-projection.test.ts b/main/services/bot-runtime/live-projection.test.ts index b3051db91..474d91c0a 100644 --- a/main/services/bot-runtime/live-projection.test.ts +++ b/main/services/bot-runtime/live-projection.test.ts @@ -26,6 +26,8 @@ import { type BotLiveProjectionDeps, } from "./live-projection.js"; import { createBotQuestions } from "./bot-questions.js"; +import { createBotTurnIntents, stopWithIntents, wireBotLiveTurnIntents } from "./bot-turn-intents.js"; +import { spawnHarnessChild } from "./test-support/child.js"; import { countingTool, recordingDeps } from "./test-support/fixtures.js"; import { optionAnswer, QUESTION_ARGS, QUESTION_TOOL_NAME, questionEntries } from "./test-support/question-fixtures.js"; import { createFauxModels, FAUX_MODEL_REF, slowAnswer, waitFor, type FauxModels } from "./test-support/faux.js"; @@ -845,6 +847,17 @@ test("the live phase reads stop, a waiting card, compaction, the newest running assert.equal(liveBotActivity({ ...base, entries: [user] as never, docs: docs({}) })?.phase, "preparing"); assert.equal(liveBotActivity({ ...base, docs: docs({}) })?.phase, "thinking"); assert.equal(liveBotActivity({ ...base, resuming: true, docs: docs({}) })?.phase, "resuming"); + // A paused run's text and tool, still in `pi.live` when the Resume began, are not the resumed run's work. + const paused = docs({ tools, generation: { attempt: 1, message: { content: [{ type: "text", text: "Half a" }] } } }); + const resumed = { ...base, resuming: true, resumedFrom: JSON.stringify(paused["pi.live"]) }; + assert.equal(liveBotActivity({ ...resumed, docs: paused })?.phase, "resuming"); + assert.equal(liveBotActivity({ ...resumed, stopping: true, docs: paused })?.phase, "stopping"); + assert.equal(liveBotActivity({ ...resumed, docs: docs({ generation: { attempt: 1 } }) })?.phase, "resuming"); + assert.equal( + liveBotActivity({ ...resumed, docs: docs({ generation: { attempt: 1, message: { content: [{ type: "text", text: "Half a" }] } } }) })?.phase, + "writing", + "once anything live happened, text is the resumed run's", + ); assert.equal( liveBotActivity({ ...base, docs: docs({ generation: { attempt: 1, message: { content: [{ type: "text", text: "Here" }] } } }) })?.phase, "writing", @@ -869,6 +882,242 @@ test("Stop pressed while nothing runs is ignored, so the next reply never starts } }); +/** A reply that waits for `open()` before the model answers: the run holds its "before any output" line. */ +function gatedAnswer(text: string) { + let open!: () => void; + const opened = new Promise((resolve) => (open = resolve)); + return { + open: () => open(), + step: async () => { + await opened; + return fauxAssistantMessage(text); + }, + }; +} + +test("each Stop and Resume intent reaches the desktop projection as its own call, until unwired", async () => { + const reported: string[] = []; + const intents = createBotTurnIntents({ + onError: (botId, intent, error) => void reported.push(`${intent} ${botId}: ${(error as Error).message}`), + }); + const calls: string[] = []; + const projection = { + notifyStopping: (botId: string) => void calls.push(`stopping ${botId}`), + cancelStopping: (botId: string) => void calls.push(`cancel ${botId}`), + notifyResuming: (botId: string) => void calls.push(`resuming ${botId}`), + }; + // A listener that throws never keeps the next one from hearing the intent. + intents.onIntent(() => { + throw new Error("a broken surface"); + }); + const unwire = wireBotLiveTurnIntents({ intents, projection: () => projection }); + + assert.equal(await stopWithIntents(intents, "bot:a", async () => "stopped"), "stopped"); + assert.deepEqual(calls, ["stopping bot:a"], "a Stop that took is never taken back"); + assert.deepEqual(reported, ["stopping bot:a: a broken surface"], "the surface that missed it is reported"); + + calls.length = 0; + const refused = new Error("The Bot is held by another Aiden."); + await assert.rejects(stopWithIntents(intents, "bot:b", async () => Promise.reject(refused)), (error) => error === refused); + assert.deepEqual(calls, ["stopping bot:b", "cancel bot:b"], "a Stop that failed takes Stopping back"); + + calls.length = 0; + intents.announce("bot:c", "resuming"); + assert.deepEqual(calls, ["resuming bot:c"]); + + calls.length = 0; + unwire(); + intents.announce("bot:a", "stopping"); + assert.deepEqual(calls, []); + // The broken listener is still registered: each intent it missed is reported once. + assert.deepEqual(reported, [ + "stopping bot:a: a broken surface", + "stopping bot:b: a broken surface", + "stopCancelled bot:b: a broken surface", + "resuming bot:c: a broken surface", + "stopping bot:a: a broken surface", + ]); +}); + +test("a Stop for one Bot leaves another Bot's running reply on its work, never Stopping", async () => { + const live = await start(createFauxModels([slowAnswer(), slowAnswer()], { tokensPerSecond: 40 }), { + service: { knownBotIds: async () => new Set(["bot:a", "bot:b"]) }, + }); + const intents = createBotTurnIntents(); + const unwire = wireBotLiveTurnIntents({ intents, projection: () => live.projection }); + try { + const tapeA = recorder(); + const tapeB = recorder(); + const a = await live.projection.subscribe("bot:a", tapeA.sink); + tapeA.start(a.snapshot); + const b = await live.projection.subscribe("bot:b", tapeB.sink); + tapeB.start(b.snapshot); + await live.service.send("bot:a", { text: "tell me a story", requestId: "desk-a" }); + await live.service.send("bot:b", { text: "tell me another", requestId: "desk-b" }); + await waitFor(() => tapeA.view?.activity?.phase === "writing" && tapeB.view?.activity?.phase === "writing", { + what: "both replies to stream", + }); + + await stopWithIntents(intents, "bot:a", () => live.service.stop("bot:a")); + await waitFor(() => tapeA.view?.activity === null && tapeA.view.state.kind === "idle", { what: "Bot A to stop" }); + assert.ok(observedPhases(tapeA.events).includes("stopping"), observedPhases(tapeA.events).join(" → ")); + // Bot B's feed has handled every intent by now: a refresh runs after it on the same queue. + await live.projection.refreshActivity("bot:b"); + assert.equal(tapeB.view?.state.kind, "running"); + assert.equal(tapeB.view?.activity?.phase, "writing"); + assert.equal(observedPhases(tapeB.events).includes("stopping"), false, observedPhases(tapeB.events).join(" → ")); + a.unsubscribe(); + b.unsubscribe(); + } finally { + unwire(); + await live.close(); + } +}); + +test("a Stop announced while nothing runs changes nothing, and the next reply starts at Getting ready", async () => { + const answer = gatedAnswer("word ".repeat(20)); + const live = await start(createFauxModels([answer.step], { tokensPerSecond: 40 })); + const intents = createBotTurnIntents(); + const unwire = wireBotLiveTurnIntents({ intents, projection: () => live.projection }); + try { + const tape = recorder(); + const { snapshot, unsubscribe } = await live.projection.subscribe("bot:a", tape.sink); + tape.start(snapshot); + // A double-tap on Stop after the last reply landed, from the Mac or a phone. + await stopWithIntents(intents, "bot:a", () => live.service.stop("bot:a")); + const sent = await live.service.send("bot:a", { text: "hi", requestId: "desk-1" }); + await waitFor(() => tape.view?.activity != null, { what: "the next reply's line" }); + assert.equal(tape.view?.activity?.phase, "preparing"); + answer.open(); + await live.service.awaitReply("bot:a", sent.submissionId, new AbortController().signal); + await waitFor(() => tape.view?.activity === null && tape.view.state.kind === "idle", { what: "the reply to land" }); + assert.equal(observedPhases(tape.events).includes("stopping"), false, observedPhases(tape.events).join(" → ")); + unsubscribe(); + } finally { + answer.open(); + unwire(); + await live.close(); + } +}); + +test("a Stop that fails stops reading Stopping and the reply goes on; the Stop after it ends the reply", async () => { + const live = await start(createFauxModels([slowAnswer()], { tokensPerSecond: 40 })); + const intents = createBotTurnIntents(); + const unwire = wireBotLiveTurnIntents({ intents, projection: () => live.projection }); + try { + const tape = recorder(); + const { snapshot, unsubscribe } = await live.projection.subscribe("bot:a", tape.sink); + tape.start(snapshot); + await live.service.send("bot:a", { text: "tell me a story", requestId: "desk-1" }); + await waitFor(() => tape.view?.activity?.phase === "writing", { what: "the reply to stream" }); + const pressed = tape.events.length; + await assert.rejects( + stopWithIntents(intents, "bot:a", async () => { + throw new Error("The Bot is held by another Aiden."); + }), + ); + const sincePressed = () => observedPhases(tape.events.slice(pressed)); + await waitFor(() => sincePressed()[sincePressed().length - 1] === "writing", { what: "the line to return to the work" }); + assert.deepEqual(observedPhases(tape.events.slice(pressed)), ["stopping", "writing"]); + assert.equal(tape.view?.state.kind, "running", "the reply goes on"); + + assert.equal((await stopWithIntents(intents, "bot:a", () => live.service.stop("bot:a"))).kind, "idle"); + await waitFor(() => tape.view?.activity === null && tape.view.state.kind === "idle", { what: "the stop to land" }); + assert.ok(observedPhases(tape.events.slice(pressed)).lastIndexOf("stopping") > 0); + unsubscribe(); + } finally { + unwire(); + await live.close(); + } +}); + +test("Resume is announced only for a Resume the session carried out, and before the resumed run is published", async () => { + const profileDir = tempProfile(); + const child = spawnHarnessChild("stream", [profileDir, "bot:killed", "req-killed"]); + await child.waitFor("STREAMING"); + await child.kill(); + const intents = createBotTurnIntents(); + const heard: string[] = []; + intents.onIntent((botId, intent) => void heard.push(`${intent} ${botId}`)); + let projection: BotLiveProjection | undefined; + const live = await start(createFauxModels([fauxAssistantMessage("resumed answer")]), { + service: { + profileDir, + knownBotIds: async () => new Set(["bot:a", "bot:killed"]), + // The desktop wiring (`bot-session-main.ts`): the session announces a Resume it carried out. + onResumed: (botId) => intents.announce(botId, "resuming"), + onStateChange: (botId, state) => { + heard.push(`${state.kind} ${botId}`); + projection?.notifyState(botId, state); + }, + }, + }); + projection = live.projection; + const unwire = wireBotLiveTurnIntents({ intents, projection: () => live.projection }); + try { + await live.service.initialize(); + heard.length = 0; + // Nothing to pick up: an idle Bot's Resume announces nothing. + assert.equal((await live.service.resume("bot:a", "desk-1")).kind, "idle"); + assert.equal(heard.length, 0, heard.join(", ")); + + await live.service.resume("bot:killed", "desk-2"); + await live.service.resume("bot:killed", "desk-2"); + // Heard before the running state, so the projection marks the run it starts. + assert.deepEqual(heard.slice(0, 2), ["resuming bot:killed", "running bot:killed"]); + await waitFor(async () => (await live.service.state("bot:killed")).kind === "idle", { what: "the resumed turn to finish" }); + assert.equal(heard.filter((line) => line.startsWith("resuming")).length, 1, heard.join(", ")); + } finally { + unwire(); + await live.close(); + } +}); + +test("a Resume after a crash mid-reply picks up where it left off; the killed run's text is not new writing", async () => { + const profileDir = tempProfile(); + const child = spawnHarnessChild("stream", [profileDir, "bot:killed", "req-killed"]); + await child.waitFor("STREAMING"); + await child.kill(); + const answer = gatedAnswer("resumed answer"); + const intents = createBotTurnIntents(); + let projection: BotLiveProjection | undefined; + const live = await start(createFauxModels([answer.step]), { + service: { + profileDir, + knownBotIds: async () => new Set(["bot:killed"]), + // The desktop wiring (`bot-session-main.ts`). + onResumed: (botId) => intents.announce(botId, "resuming"), + onStateChange: (botId, state) => projection?.notifyState(botId, state), + }, + }); + projection = live.projection; + const unwire = wireBotLiveTurnIntents({ intents, projection: () => live.projection }); + try { + await live.service.initialize(); + const tape = recorder(); + const { snapshot, unsubscribe } = await live.projection.subscribe("bot:killed", tape.sink); + tape.start(snapshot); + assert.equal(snapshot.state.kind, "interrupted"); + assert.ok(snapshot.partial?.includes("word"), "the killed run's text is still on screen"); + + await live.service.resume("bot:killed", "desk-1"); + // The model has not answered yet: the line holds through the resumed run's first live events. + await waitFor(() => tape.view?.activity != null, { what: "the resumed run's line" }); + await live.projection.refreshActivity("bot:killed"); + assert.equal(tape.view?.activity?.phase, "resuming", observedPhases(tape.events).join(" → ")); + answer.open(); + await waitFor(() => tape.view?.activity === null && tape.view.state.kind === "idle", { what: "the resumed turn to land" }); + const phases = observedPhases(tape.events).filter((phase) => phase !== null); + assert.equal(phases[0], "resuming", phases.join(" → ")); + assert.ok(inOrder(phases, ["resuming", "writing"]), phases.join(" → ")); + unsubscribe(); + } finally { + answer.open(); + unwire(); + await live.close(); + } +}); + /** Every distinct (turn start, phase) the line showed, in order. */ function observedTurns(events: readonly BotLiveEvent[]): Array<{ startedAt: number; phase: string }> { const seen: Array<{ startedAt: number; phase: string }> = []; @@ -1039,10 +1288,13 @@ test("queued inputs are listed by the person's ids only; passive writes never sh }, }; assert.deepEqual(liveQueuedInputIds(docs), ["2", "4", "5"]); - assert.deepEqual(liveQueuedMessages(docs, new Set(["2", "4"])), [ + // Only the ids classified as the person's own are listed; 2 and 4 are a routine's and the self-intro. + assert.deepEqual(liveQueuedMessages(docs, new Set(["5"])), [ { submissionId: "5", mode: "steer", text: "Make it vegetarian", imageCount: 1 }, ]); - assert.deepEqual(liveQueuedMessages({}), []); + // An id not classified yet (or a passive write's) never shows. + assert.deepEqual(liveQueuedMessages(docs, new Set(["1"])), []); + assert.deepEqual(liveQueuedMessages({}, new Set(["5"])), []); }); /** Wait, outside the projection, until the conversation's `pi.inbox` satisfies `predicate`. */ diff --git a/main/services/bot-runtime/live-projection.ts b/main/services/bot-runtime/live-projection.ts index 6df0c5ad3..8d7deeed8 100644 --- a/main/services/bot-runtime/live-projection.ts +++ b/main/services/bot-runtime/live-projection.ts @@ -413,9 +413,10 @@ export function livePartialText(docs: Readonly>): string * The turn's identity, or null when no run: its first input. A queued follow-up * starts a new run (fresh inputs) while `pi.live.run` stays present; steering * appends to the inputs and a tool round hands the run to a new generation - * task, so neither changes the key (and neither is a new turn). + * task, so neither changes the key (and neither is a new turn). Remote + * projectors scope a run's start time and Stop by the same key. */ -function liveRunKey(docs: Readonly>): string | null { +export function liveRunKey(docs: Readonly>): string | null { const live = docs["pi.live"] as { run?: { inputs?: unknown } } | undefined; const run = live?.run; if (run === undefined) return null; @@ -423,6 +424,45 @@ function liveRunKey(docs: Readonly>): string | null { return String(first); } +/** + * When each Bot's current run started, as first seen by any live surface in + * this process. The desktop feed and every Remote projector share one, so a + * surface that opens mid-run, or reopens after an idle close, shows the run's + * real elapsed time instead of restarting the clock. Nothing durable records + * a run's start, so a run already going when Aiden started is timed from when + * a surface first saw it. + */ +export interface BotRunStarts { + /** + * The start of `botId`'s run `runKey` (`liveRunKey`), recorded from `now` + * the first time it is asked for. A null key (the run is not in `pi.live` + * yet, or is just leaving) belongs to the run already recorded. + */ + startedAt(botId: string, runKey: string | null, now: () => number): number; + /** A surface saw `botId` not running: its next run, even a Resume of the same one, starts afresh. */ + ended(botId: string): void; +} + +export function createBotRunStarts(): BotRunStarts { + // One run per Bot: a new run replaces the last, so this stays bounded by the Bots. + const runs = new Map(); + return { + startedAt(botId, runKey, now) { + const known = runs.get(botId); + if (known && (known.runKey === runKey || known.runKey === null || runKey === null)) { + if (known.runKey === null) known.runKey = runKey; + return known.at; + } + const at = now(); + runs.set(botId, { runKey, at }); + return at; + }, + ended(botId) { + runs.delete(botId); + }, + }; +} + function liveRunActive(docs: Readonly>): boolean { const live = docs["pi.live"] as { run?: unknown } | undefined; return live?.run !== undefined; @@ -444,6 +484,13 @@ export interface BotLiveActivityInput { stopping: boolean; /** This run is a Resume and has not moved on yet. */ resuming: boolean; + /** + * `pi.live` as it was when the Resume began, serialized once then + * (`JSON.stringify`). While it is unchanged nothing live has happened since, + * so a killed run's partial text or tool still in it predates the Resume and + * is not the resumed run's work. + */ + resumedFrom?: string; startedAt: number; } @@ -470,10 +517,11 @@ function callArguments(entries: readonly EntryRecord[], callId: string): unknown /** * The phase of a run from `pi.live` (spec §5): Stop, then an open card, - * then compaction inside the turn, then the newest running tool (§5.2), then - * the reply's text, then Resume, then thinking (or "getting ready" before - * the model produced anything). Thinking content is read only to know the - * model is generating; its text is never copied. + * then a Resume nothing live has happened since, then compaction inside the + * turn, then the newest running tool (§5.2), then the reply's text, then + * Resume, then thinking (or "getting ready" before the model produced + * anything). Thinking content is read only to know the model is generating; + * its text is never copied. */ export function liveBotActivity(input: BotLiveActivityInput): BotLiveActivity | null { const live = input.docs["pi.live"] as LiveDocView | undefined; @@ -481,6 +529,10 @@ export function liveBotActivity(input: BotLiveActivityInput): BotLiveActivity | const at = (phase: BotActivityPhase): BotLiveActivity => ({ phase, startedAt: input.startedAt }); if (input.stopping) return at("stopping"); if (input.waiting) return at("waiting"); + // After a crash the paused run's text stays in `pi.live` until the resumed run's first event. + if (input.resuming && input.resumedFrom !== undefined && input.resumedFrom === JSON.stringify(live)) { + return at("resuming"); + } if (live.compactions?.some((compaction) => compaction.blocking === true)) return at("tidying"); const slot = pickActiveToolSlot(live.tools); if (slot) { @@ -513,13 +565,14 @@ export function liveQueuedInputIds(docs: Readonly>): str } /** - * Inputs waiting in `pi.inbox` that the person sent. `quiet` holds the ids of - * routine and hidden (self-intro) submissions, which are decided by their - * request id and never shown. + * Inputs waiting in `pi.inbox` that the person sent. Only ids in `personal` + * (classified as the person's own, `createQueuedInputClassifier`) are listed, + * so a routine's or the self-intro's input, or one not classified yet, never + * shows: the list is private by construction. */ export function liveQueuedMessages( docs: Readonly>, - quiet: ReadonlySet = new Set(), + personal: ReadonlySet, ): BotQueuedMessage[] { const items = (docs["pi.inbox"] as { items?: unknown } | undefined)?.items; if (!Array.isArray(items)) return []; @@ -530,12 +583,92 @@ export function liveQueuedMessages( if (typeof item.id !== "number" && typeof item.id !== "string") continue; const submissionId = String(item.id); const content = item.content as UserContent | undefined; - if (content === undefined || quiet.has(submissionId)) continue; + if (content === undefined || !personal.has(submissionId)) continue; queued.push({ submissionId, mode: item.mode, text: textOf(content), imageCount: imageCount(content) }); } return queued; } +export interface QueuedInputClassifierDeps { + /** + * `quietInputs` for one Bot: `true` for a routine's input or the hidden + * self-intro, `false` for the person's own. An id it cannot find is left out. + */ + classify(submissionIds: readonly string[]): Promise>; + now(): number; + /** Pause before a failed or incomplete classification is tried again. */ + retryMs: number; + /** A classification failed: called once per failing streak. */ + report(error: unknown): void; + /** The pause is over: project again (on the caller's own serial lane), even if no frame arrives. */ + retry(): void; +} + +export interface QueuedInputClassifier { + /** + * The ids of the inputs waiting in `docs` that are known to be the person's + * own. Each id is asked about once. One with no answer yet, or whose lookup + * failed, is left out (fail closed) and asked again after the pause, never + * on every frame. + */ + personal(docs: Readonly>): Promise>; + /** Cancel a pending retry. */ + close(): void; +} + +/** The queued-input classification the desktop feed and every Remote projector share. */ +export function createQueuedInputClassifier(deps: QueuedInputClassifierDeps): QueuedInputClassifier { + /** Queued submission ids already classified as quiet (true) or the person's (false). */ + const kinds = new Map(); + let failing = false; + /** No classification before this time: a persistent failure is not retried on every frame. */ + let notBefore = 0; + let timer: ReturnType | undefined; + let closed = false; + + function scheduleRetry(): void { + notBefore = Math.max(notBefore, deps.now() + deps.retryMs); + if (timer !== undefined || closed) return; + timer = setTimeout(() => { + timer = undefined; + if (closed) return; + notBefore = 0; + deps.retry(); + }, deps.retryMs); + timer.unref?.(); + } + + return { + async personal(docs) { + const ids = liveQueuedInputIds(docs); + for (const id of [...kinds.keys()]) if (!ids.includes(id)) kinds.delete(id); + const unknown = ids.filter((id) => !kinds.has(id)); + if (unknown.length > 0 && deps.now() >= notBefore) { + try { + const answers = await deps.classify(unknown); + for (const id of unknown) { + const quiet = answers.get(id); + if (quiet !== undefined) kinds.set(id, quiet); + } + failing = false; + } catch (error) { + if (!failing) deps.report(error); + failing = true; + } + if (unknown.some((id) => !kinds.has(id))) scheduleRetry(); + } else if (unknown.length > 0) { + scheduleRetry(); + } + return new Set(ids.filter((id) => kinds.get(id) === false)); + }, + close() { + closed = true; + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + }, + }; +} + /* ------------------------------------------------------------------------- */ /* Feeds and subscribers */ /* ------------------------------------------------------------------------- */ @@ -560,6 +693,12 @@ export interface BotLiveProjectionDeps { approvalPending?(botId: string): boolean; /** Clock for `startedAt`; defaults to `Date.now`. */ now?(): number; + /** + * Run start times shared with the Remote projectors (`botRunStarts`), so + * every surface times a run from the same moment. Defaults to this + * projection's own. + */ + runStarts?: BotRunStarts; /** The current status of a connect card (connected or dismissed since it was offered). */ connectCardStatus?(botId: string, card: ConnectCardEntry): Promise; /** Schedules a subscriber flush. Defaults to the next macrotask. */ @@ -622,17 +761,14 @@ interface Feed { question: BotPendingQuestion | null; activity: BotLiveActivity | null; queued: BotQueuedMessage[]; - /** Queued submission ids already classified as quiet (true) or the person's (false). */ - inputKinds: Map; - /** Classifying failed and has not succeeded since; logged once per streak. */ - classifyFailing: boolean; - /** No classification attempt before this time (a throttle for a persistent failure). */ - classifyNotBefore: number; - classifyRetry: ReturnType | null; + /** Which queued inputs are the person's own (fail closed, throttled retries). */ + classifier: QueuedInputClassifier; /** When the current running turn started. */ startedAt: number; stopping: boolean; resuming: boolean; + /** `pi.live` when this run's Resume began, serialized (`BotLiveActivityInput.resumedFrom`). */ + resumedFrom: string | undefined; /** The newest frame, so approvals, questions and Stop can re-derive the activity. */ frame: { entries: readonly EntryRecord[]; docs: Readonly> }; runActive: boolean; @@ -654,6 +790,7 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro const schedule = deps.schedule ?? ((flush: () => void) => void setTimeout(flush, 0)); const newEpoch = deps.newEpoch ?? (() => randomUUID()); const now = deps.now ?? Date.now; + const runStarts = deps.runStarts ?? createBotRunStarts(); /** Bots whose next run is a Resume. */ const resumingBots = new Set(); const feeds = new Map(); @@ -748,6 +885,7 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro waiting: waitingOn(feed), stopping: feed.stopping, resuming: feed.resuming, + resumedFrom: feed.resumedFrom, startedAt: feed.startedAt, }) ?? { phase: "preparing", startedAt: feed.startedAt } ); @@ -763,9 +901,11 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro /** A new run began: its start time, Stop and Resume marks belong to it alone. */ function startRun(feed: Feed): void { - feed.startedAt = now(); + // A run another surface already saw keeps the time it started. + feed.startedAt = runStarts.startedAt(feed.botId, liveRunKey(feed.frame.docs), now); feed.stopping = false; feed.resuming = resumingBots.delete(feed.botId); + feed.resumedFrom = feed.resuming ? JSON.stringify(feed.frame.docs["pi.live"]) : undefined; } async function resolveCards(botId: string, entries: BotTranscriptEntry[]): Promise { @@ -796,52 +936,21 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro for (const entry of next.slice(previous.length)) emit(feed, { type: "entry", entry }); } - /** - * The person's queued messages. Each queued id is classified once; an id that - * is not yet classified as the person's own stays hidden (fail closed), so a - * routine's prompt never shows as a message the person sent. A failed or - * incomplete classification is retried after a pause, not on every frame. - */ + /** The person's queued messages: only inputs classified as theirs (`createQueuedInputClassifier`). */ async function queuedFor(feed: Feed, docs: Readonly>): Promise { - const ids = liveQueuedInputIds(docs); - for (const id of [...feed.inputKinds.keys()]) if (!ids.includes(id)) feed.inputKinds.delete(id); - const unknown = ids.filter((id) => !feed.inputKinds.has(id)); - if (unknown.length > 0 && now() >= feed.classifyNotBefore) { - try { - const answers = await deps.quietInputs(feed.botId, unknown); - for (const id of unknown) { - const quiet = answers.get(id); - if (quiet !== undefined) feed.inputKinds.set(id, quiet); - } - feed.classifyFailing = false; - } catch (error) { - if (!feed.classifyFailing) report(feed.botId, error); - feed.classifyFailing = true; - } - if (unknown.some((id) => !feed.inputKinds.has(id))) scheduleClassifyRetry(feed); - } else if (unknown.length > 0) { - scheduleClassifyRetry(feed); - } - return liveQueuedMessages(docs, new Set(ids.filter((id) => feed.inputKinds.get(id) !== false))); + return liveQueuedMessages(docs, await feed.classifier.personal(docs)); } - /** One retry after the pause, through the feed queue, even if no frame arrives. */ - function scheduleClassifyRetry(feed: Feed): void { - feed.classifyNotBefore = Math.max(feed.classifyNotBefore, now() + classifyRetryMs); - if (feed.classifyRetry !== null || feed.ended) return; - feed.classifyRetry = setTimeout(() => { - feed.classifyRetry = null; - void enqueueWork(feed, async () => { - if (feed.ended) return; - feed.classifyNotBefore = 0; - const queued = await queuedFor(feed, feed.frame.docs); - if (JSON.stringify(queued) !== JSON.stringify(feed.queued)) { - feed.queued = queued; - emit(feed, { type: "queued", queued }); - } - }); - }, classifyRetryMs); - feed.classifyRetry.unref?.(); + /** The classification pause is over: list the queued messages again, through the feed queue. */ + function requeue(feed: Feed): void { + void enqueueWork(feed, async () => { + if (feed.ended) return; + const queued = await queuedFor(feed, feed.frame.docs); + if (JSON.stringify(queued) !== JSON.stringify(feed.queued)) { + feed.queued = queued; + emit(feed, { type: "queued", queued }); + } + }); } async function onFrame(feed: Feed, entries: readonly EntryRecord[], docs: Readonly>) { @@ -853,6 +962,8 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro startRun(feed); } feed.runKey = runKey; + // Records the run's key as soon as it shows, so a replacing run is told apart from this one. + if (feed.state.kind === "running") feed.startedAt = runStarts.startedAt(feed.botId, runKey, now); applyEntries(feed, await resolveCards(feed.botId, projectBotTranscript(entries))); const partial = livePartialText(docs); if (partial !== feed.partial) { @@ -891,6 +1002,7 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro if (state.kind !== "running") { feed.stopping = false; feed.resuming = false; + runStarts.ended(feed.botId); } emit(feed, { type: "state", state }); updateActivity(feed); @@ -912,6 +1024,16 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro const { context, cancel } = withCancel(BACKGROUND_CONTEXT); const watch = await conversation.watch(context); const initial = watch.value; + let opened: Feed | undefined; + const classifier = createQueuedInputClassifier({ + classify: (ids) => deps.quietInputs(botId, ids), + now, + retryMs: classifyRetryMs, + report: (error) => report(botId, error), + retry: () => { + if (opened) requeue(opened); + }, + }); const feed: Feed = { botId, epoch: newEpoch(), @@ -922,13 +1044,11 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro question: deps.question?.(botId) ?? null, activity: null, queued: [], - inputKinds: new Map(), - classifyFailing: false, - classifyNotBefore: 0, - classifyRetry: null, + classifier, startedAt: now(), stopping: false, resuming: false, + resumedFrom: undefined, frame: { entries: initial.entries, docs: initial.docs }, runActive: liveRunActive(initial.docs), runKey: liveRunKey(initial.docs), @@ -937,7 +1057,9 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro ended: false, queue: Promise.resolve(), }; + opened = feed; if (feed.state.kind === "running") startRun(feed); + else runStarts.ended(botId); feed.activity = computeActivity(feed); feed.queued = await queuedFor(feed, initial.docs); feeds.set(botId, feed); @@ -978,8 +1100,7 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro async function stopFeed(feed: Feed): Promise { feed.ended = true; - if (feed.classifyRetry !== null) clearTimeout(feed.classifyRetry); - feed.classifyRetry = null; + feed.classifier.close(); if (feeds.get(feed.botId) === feed) feeds.delete(feed.botId); await feed.watch.stop().catch(() => undefined); feed.cancel(); diff --git a/main/services/llm-client.ts b/main/services/llm-client.ts index 05ab06262..fae897f59 100644 --- a/main/services/llm-client.ts +++ b/main/services/llm-client.ts @@ -358,6 +358,7 @@ import { createPiToolDiscovery } from "./pi-tool-discovery.js"; import { isPiCodemodeCallable, partitionPiCodemodeTools } from "./pi-runtime-tool.js"; import { PiAgentRuntimeHarness, + isPiManagedCancellationError, piAgentRuntimeExtensions, resolvePiAgentRuntimeContributionSnapshot, resolvePiAgentRuntimeStaticContributions, @@ -2643,6 +2644,7 @@ export const llmClient = { let currentPromptMessage: AgentMessage | undefined; let journalContentOverrides: ReadonlyMap = new Map(); let piJournalHealthy = true; + let piJournalStopNoted = false; let piJournalless = false; let piUpgradeCompactionEnabled = false; let memoryApprovalContext: { scope: MemoryScope; provenance: MemoryProvenance } | undefined; @@ -3273,7 +3275,19 @@ export const llmClient = { } : {}), onJournalError: (error) => { + // A Stop-interrupted write may still land, so the turn is recovered + // from the visible chat either way. Only a real failure is an error. piJournalHealthy = false; + if (isPiManagedCancellationError(error)) { + if (!piJournalStopNoted) { + piJournalStopNoted = true; + logger.info( + "pi", + `Stop interrupted Pi session writes for stream ${streamId}; the visible turn will be recovered.`, + ); + } + return; + } logger.error( "pi", `Could not append Pi session messages for stream ${streamId}.`, diff --git a/main/services/pi-agent-runtime-harness.test.ts b/main/services/pi-agent-runtime-harness.test.ts index e97c861ce..3a51bd5f1 100644 --- a/main/services/pi-agent-runtime-harness.test.ts +++ b/main/services/pi-agent-runtime-harness.test.ts @@ -15,10 +15,16 @@ import { import { type AfterToolCallResult, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core"; import { convertToLlm, createCompactionSummaryMessage } from "./pi-legacy-harness.js"; import { createModels } from "@earendil-works/pi-ai"; -import { appendPiMessages, syncChatMessagesToPiSession } from "./pi-compaction-session-store.js"; +import { + appendPiMessages, + beginPiVisibleTurnLease, + syncChatMessagesToPiSession, + type PiVisibleTurnLease, +} from "./pi-compaction-session-store.js"; import { PiCompactionCoordinator } from "./pi-compaction-core.js"; import { buildAgentRuntimeOptions } from "./generation-runtime.js"; import { + isPiManagedCancellationError, PiAgentRuntimeExtensionRegistry, PiAgentRuntimeHarness, PiAgentRuntimeHostError, @@ -36,7 +42,7 @@ import { createGenerationContextTransform } from "./generation-context.js"; import { createPiSessionPort, type PiSessionPort } from "./pi-session-port.js"; import { flushDiagnosticJournal, initDiagnosticJournal } from "./diagnostic-journal.js"; import { createChatRunInputAdmission } from "./chat-run-input-admission.js"; -import type { Chat } from "./types.js"; +import type { Chat, ChatMessage } from "./types.js"; function testHarness( responses: Parameters["setResponses"]>[0], @@ -87,6 +93,7 @@ async function managedTestHarness( summaryResponses?: Parameters["setResponses"]>[0]; generationContextTransform?: boolean; onContextProjection?: PiAgentRuntimeHarnessOptions["onContextProjection"]; + onJournalError?: PiRuntimeSessionBinding["onJournalError"]; } = {}, ) { const core = createFauxCore({ @@ -168,6 +175,7 @@ async function managedTestHarness( }, ...(options.signal ? { signal: options.signal } : {}), ...(options.effects ? { effects: options.effects } : {}), + ...(options.onJournalError ? { onJournalError: options.onJournalError } : {}), }, }); return { core, harness, session }; @@ -1013,6 +1021,7 @@ test("managed run fails closed when a tool plan cannot become durable", async () return { content: [{ type: "text", text: "unsafe" }], details: null }; }, }; + const journalErrors: unknown[] = []; const { harness } = await managedTestHarness( [ fauxAssistantMessage([fauxToolCall(tool.name, {})], { @@ -1027,6 +1036,7 @@ test("managed run fails closed when a tool plan cannot become durable", async () } await appendPiMessages(session, messages); }, + onJournalError: (error) => journalErrors.push(error), }, ); @@ -1043,6 +1053,9 @@ test("managed run fails closed when a tool plan cannot become durable", async () assert.equal(outcome.kind === "host_failed" ? outcome.faultKind : undefined, "session"); assert.equal(toolCalls, 0); assert.doesNotMatch(JSON.stringify(outcome), /PRIVATE_JOURNAL_CANARY/u); + // A real write failure must stay distinguishable from a Stop. + assert.ok(journalErrors.length > 0); + assert.equal(journalErrors.some(isPiManagedCancellationError), false); }); test("managed before-tool policy faults stop before another provider request", async () => { @@ -1396,6 +1409,97 @@ test("managed cancellation settles while input journaling is still pending", asy assert.equal(core.state.callCount, 0); }); +test("Stop mid-stream reports only a cancellation to the journal and the visible turn recovers intact", async () => { + const journalErrors: unknown[] = []; + let streaming!: () => void; + const atStream = new Promise((resolve) => { + streaming = resolve; + }); + const { core, harness, session } = await managedTestHarness([], { + onJournalError: (error) => journalErrors.push(error), + streamFn: (model, _context, options) => { + const stream = createAssistantMessageEventStream(); + const partial = { + ...fauxAssistantMessage("partial answer"), + api: model.api, + provider: model.provider, + model: model.id, + }; + queueMicrotask(() => { + stream.push({ type: "start", partial }); + options?.signal?.addEventListener( + "abort", + () => stream.push({ type: "error", reason: "aborted", error: { ...partial, stopReason: "aborted" } }), + { once: true }, + ); + streaming(); + }); + return stream; + }, + }); + const model = core.getModel(); + const visible = (id: string, role: "user" | "assistant", content: string, createdAt: number): ChatMessage => ({ + id, + role, + content, + createdAt, + }); + const journalText = async () => + (await session.buildContext()).messages.map((message) => { + if (message.role !== "user" && message.role !== "assistant") return [message.role, ""]; + const text = + typeof message.content === "string" + ? message.content + : message.content.map((part) => (part.type === "text" ? part.text : "")).join(""); + return [message.role, text]; + }); + await syncChatMessagesToPiSession( + session, + [visible("u1", "user", "earlier question", 1), visible("a1", "assistant", "earlier answer", 2)], + model, + false, + ); + const before = await journalText(); + + let lease: PiVisibleTurnLease | undefined; + const running = harness.runManaged( + { kind: "append-and-run", message: { role: "user", content: "new question", timestamp: 3 } }, + { + beforeDurableTurn: async () => { + lease = await beginPiVisibleTurnLease(session); + }, + }, + ); + await atStream; + const settled = await harness.cancelAndSettle(); + assert.equal(settled?.kind, "app_cancelled"); + assert.equal((await running).kind, "app_cancelled"); + + // Stop interrupts the journal writes the aborted reply would make. Those are + // reported as a cancellation, never as a write failure. + assert.ok(journalErrors.length > 0); + assert.ok(journalErrors.every(isPiManagedCancellationError)); + + // The host's recovery: wait for detached writes, roll the turn back, then + // re-journal what the visible chat kept. + await harness.pendingDurabilitySettlement(); + assert.ok(lease?.started); + await lease.rollback(); + assert.deepEqual(await journalText(), before); + await syncChatMessagesToPiSession( + session, + [visible("u2", "user", "new question", 3), visible("a2", "assistant", "partial answer", 4)], + model, + false, + ); + assert.deepEqual(await journalText(), [ + ["user", "earlier question"], + ["assistant", "earlier answer"], + ["user", "new question"], + ["assistant", "partial answer"], + ]); +}); + test("managed startup keeps a prior failed assistant durable", async () => { const { harness, session } = await managedTestHarness([fauxAssistantMessage("recovered")]); const model = harness.state.model; diff --git a/main/services/pi-agent-runtime-harness.ts b/main/services/pi-agent-runtime-harness.ts index 30f340000..b0dd1f08b 100644 --- a/main/services/pi-agent-runtime-harness.ts +++ b/main/services/pi-agent-runtime-harness.ts @@ -318,6 +318,15 @@ class PiManagedCancellationError extends Error { } } +/** + * True when `onJournalError` reports that Stop interrupted a journal write + * rather than that the write failed. The write may still land, so the host + * must still treat the journal as unhealthy and recover the visible turn. + */ +export function isPiManagedCancellationError(error: unknown): boolean { + return error instanceof PiManagedCancellationError; +} + function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } diff --git a/protocol/aiden-remote/v1/fixtures/contract.json b/protocol/aiden-remote/v1/fixtures/contract.json index d5199d9af..69534bb6c 100644 --- a/protocol/aiden-remote/v1/fixtures/contract.json +++ b/protocol/aiden-remote/v1/fixtures/contract.json @@ -1,5 +1,5 @@ { - "contractRevision": 29, + "contractRevision": 30, "providerCreation": {"label":"Fixture private model","baseUrl":"https://models.example.test/v1","kind":"openai","deployment":"hosted","needsKey":false,"models":[{"id":"fixture-vision","vision":true,"reasoning":false,"toolCall":true}],"confirmedForeground":true}, "providerCreationReceipt": {"id":"custom:remote-fixture","label":"Fixture private model","models":["fixture-vision"]}, "protocolVersion": 1, @@ -1491,7 +1491,7 @@ "instanceId": "instance_fixture_01", "displayName": "Fixture Aiden", "platform": "mac", - "contractRevision": 29, + "contractRevision": 30, "pairingRequests": true }, "hostFeedEvents": [ @@ -2105,5 +2105,229 @@ "hasOlder": false, "question": null, "approval": null + }, + "botActivity": { + "phases": [ + { "id": "preparing", "word": "Getting ready", "mark": "bounce" }, + { "id": "thinking", "word": "Thinking", "mark": "tri-step" }, + { "id": "reading", "word": "Reading", "mark": "scan-grid" }, + { "id": "photo", "word": "Looking at your photo", "mark": "scan-grid" }, + { "id": "searching", "word": "Searching the web", "mark": "scan-grid" }, + { "id": "command", "word": "Running a command", "mark": "quad-shuffle" }, + { "id": "editing", "word": "Editing files", "mark": "quad-shuffle" }, + { "id": "app", "word": "Using an app", "mark": "quad-shuffle" }, + { "id": "helper", "word": "Asking a helper", "mark": "quad-shuffle" }, + { "id": "computer", "word": "Using your Mac", "mark": "quad-shuffle" }, + { "id": "remembering", "word": "Remembering", "mark": "compose" }, + { "id": "routine", "word": "Setting up a routine", "mark": "tri-step" }, + { "id": "working", "word": "Working", "mark": "quad-shuffle" }, + { "id": "writing", "word": "Writing", "mark": "compose" }, + { "id": "waiting", "word": "Waiting for you", "mark": "glance" }, + { "id": "tidying", "word": "Tidying up its notes", "mark": "bounce" }, + { "id": "stopping", "word": "Stopping", "mark": "bounce" }, + { "id": "resuming", "word": "Picking up where it left off", "mark": "bounce" }, + { "id": "reconnecting", "word": "Reconnecting to your Mac", "mark": "bounce" } + ], + "thinkingKeeps": ["waiting", "stopping", "reconnecting"], + "app": { "app": "Notion", "word": "Using Notion" }, + "elapsed": [ + { "startedAt": 1760450000000, "now": 1760450019999, "label": null }, + { "startedAt": 1760450000000, "now": 1760450020000, "label": "20s" }, + { "startedAt": 1760450000000, "now": 1760450072000, "label": "1m 12s" }, + { "startedAt": 1760450000000, "now": 1760450120000, "label": "2m" }, + { "startedAt": 1760450000000, "now": 1760453720000, "label": "1h 2m" }, + { "startedAt": 1760450060000, "now": 1760450000000, "label": null } + ], + "session": { + "botId": "bot_fixture_01", + "epoch": "epoch_fixture_02", + "seq": 7, + "state": "running", + "interrupted": false, + "entries": [ + { "type": "message", "id": "entry_30", "role": "user", "text": "Plan dinners for this week.", "createdAt": "2026-10-14T09:00:00.000Z", "submissionId": "17" } + ], + "hasOlder": false, + "question": null, + "approval": null, + "activity": { "phase": "app", "app": "Notion", "startedAt": 1760450000000 }, + "queued": [ + { "submissionId": "18", "mode": "queue", "text": "Also add a shopping list." }, + { "submissionId": "19", "mode": "steer", "text": "Keep it vegetarian." } + ] + }, + "events": [ + { "protocolVersion": 1, "botId": "bot_fixture_01", "epoch": "epoch_fixture_02", "seq": 8, "type": "activity", "payload": { "activity": { "phase": "searching", "startedAt": 1760450000000 } } }, + { "protocolVersion": 1, "botId": "bot_fixture_01", "epoch": "epoch_fixture_02", "seq": 9, "type": "queued", "payload": { "queued": [ { "submissionId": "18", "mode": "queue", "text": "Also add a shopping list." } ] } }, + { "protocolVersion": 1, "botId": "bot_fixture_01", "epoch": "epoch_fixture_02", "seq": 10, "type": "activity", "payload": { "activity": { "phase": "teleporting", "startedAt": 1760450000000 } } }, + { "protocolVersion": 1, "botId": "bot_fixture_01", "epoch": "epoch_fixture_02", "seq": 11, "type": "activity", "payload": { "activity": null } } + ], + "entryEvent": { "protocolVersion": 1, "botId": "bot_fixture_01", "epoch": "epoch_fixture_02", "seq": 12, "type": "entry", "payload": { "entry": { "type": "message", "id": "entry_32", "role": "user", "text": "Also add a shopping list.", "createdAt": "2026-10-14T09:01:00.000Z", "submissionId": "18" } } }, + "detail": { + "id": "bot_fixture_01", + "name": "Scout", + "purpose": "Finds the signal in a busy week", + "instructions": "Find the important signal, explain it plainly, and keep the owner in control.", + "avatar": { "semantic": { "version": 1, "shape": "orb", "color": "rose" }, "asset": { "assetRevision": "avatar_revision_3", "mimeType": "image/png", "width": 512, "height": 512, "byteSize": 1024 } }, + "health": "ready", + "sessionState": "running", + "access": { "botId": "bot_fixture_01", "accessMode": "full", "revision": "bot_policy_revision_4", "policyEpoch": "bot_policy_epoch_4", "summary": "Can use your Mac, shell, enabled connections, and skills." }, + "chatActivity": { "level": "summary", "wording": "thinking" }, + "createdAt": "2026-08-18T17:00:00.000Z", + "updatedAt": "2026-08-18T18:46:00.000Z", + "revision": "bot_revision_8" + }, + "identityPatch": { + "request": { "chatActivity": { "level": "detailed", "wording": "follows" } }, + "response": { + "id": "bot_fixture_01", + "name": "Scout", + "purpose": "Finds the signal in a busy week", + "instructions": "Find the important signal, explain it plainly, and keep the owner in control.", + "avatar": { "semantic": { "version": 1, "shape": "orb", "color": "rose" }, "asset": { "assetRevision": "avatar_revision_3", "mimeType": "image/png", "width": 512, "height": 512, "byteSize": 1024 } }, + "health": "ready", + "access": { "botId": "bot_fixture_01", "accessMode": "full", "revision": "bot_policy_revision_4", "policyEpoch": "bot_policy_epoch_4", "summary": "Can use your Mac, shell, enabled connections, and skills." }, + "chatActivity": { "level": "detailed", "wording": "follows" }, + "createdAt": "2026-08-18T17:00:00.000Z", + "updatedAt": "2026-08-18T18:47:00.000Z", + "revision": "bot_revision_9" + } + }, + "send": { + "request": { "text": "Keep it vegetarian.", "whenBusy": "steer" }, + "response": { "submissionId": "19", "deduped": false, "state": "running", "interrupted": false } + } + }, + "botActivityPrivacy": { + "note": "Every decoder must refuse each sample: only phase, app and startedAt cross the wire.", + "nonHolder": { + "note": "The same session and entry frame as botActivity, as a device without bot:activity receives them: no activity or queued keys, and no submissionId on any entry.", + "session": { + "botId": "bot_fixture_01", + "epoch": "epoch_fixture_02", + "seq": 7, + "state": "running", + "interrupted": false, + "entries": [ + { "type": "message", "id": "entry_30", "role": "user", "text": "Plan dinners for this week.", "createdAt": "2026-10-14T09:00:00.000Z" } + ], + "hasOlder": false, + "question": null, + "approval": null + }, + "entryEvent": { "protocolVersion": 1, "botId": "bot_fixture_01", "epoch": "epoch_fixture_02", "seq": 12, "type": "entry", "payload": { "entry": { "type": "message", "id": "entry_32", "role": "user", "text": "Also add a shopping list.", "createdAt": "2026-10-14T09:01:00.000Z" } } } + }, + "rejectedSubmissionIdEntries": [ + { "type": "message", "id": "entry_31", "role": "assistant", "text": "Here is a plan.", "submissionId": "17" }, + { "type": "message", "id": "entry_33", "role": "user", "text": "Check my inbox.", "label": "Morning brief", "submissionId": "21" }, + { "type": "message", "id": "entry_30", "role": "user", "text": "Plan dinners for this week.", "submissionId": "17 18" }, + { "type": "message", "id": "entry_30", "role": "user", "text": "Plan dinners for this week.", "submissionId": "" }, + { "type": "message", "id": "entry_30", "role": "user", "text": "Plan dinners for this week.", "submissionId": "abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcde" }, + { "type": "message", "id": "entry_30", "role": "user", "text": "Plan dinners for this week.", "submissionId": 17 } + ], + "rejectedActivities": [ + { "phase": "command", "startedAt": 1760450000000, "detail": "cat ~/Documents/plan.md" }, + { "phase": "reading", "startedAt": 1760450000000, "step": { "kind": "reading", "label": "Reading", "detail": "plan.md" } }, + { "phase": "reading", "startedAt": 1760450000000, "path": "~/Documents/plan.md" }, + { "phase": "command", "startedAt": 1760450000000, "command": "ls ~/Documents" }, + { "phase": "searching", "startedAt": 1760450000000, "query": "dinner ideas near me" }, + { "phase": "thinking", "startedAt": 1760450000000, "thinking": "The person wants a plan." }, + { "phase": "app", "app": "abcdefghijabcdefghijabcdefghijabcdefghijX", "startedAt": 1760450000000 }, + { "phase": "Reading!", "startedAt": 1760450000000 }, + { "phase": "thinking", "startedAt": -1 }, + { "phase": "thinking", "startedAt": "1760450000000" }, + { "phase": "thinking", "startedAt": 1.5 }, + { "phase": "thinking", "startedAt": 9007199254740992 }, + { "startedAt": 1760450000000 } + ], + "rejectedQueued": [ + { "submissionId": "18", "mode": "followUp", "text": "Also add a shopping list." }, + { "submissionId": "18", "mode": "queue", "text": "Also add a shopping list.", "images": 1 }, + { "submissionId": "18 19", "mode": "queue", "text": "Also add a shopping list." } + ], + "rejectedQueuedLists": [ + [ + { "submissionId": "18", "mode": "queue", "text": "Also add a shopping list." }, + { "submissionId": "18", "mode": "steer", "text": "Keep it vegetarian." } + ], + [ + { "submissionId": "1", "mode": "queue", "text": "x" }, + { "submissionId": "2", "mode": "queue", "text": "x" }, + { "submissionId": "3", "mode": "queue", "text": "x" }, + { "submissionId": "4", "mode": "queue", "text": "x" }, + { "submissionId": "5", "mode": "queue", "text": "x" }, + { "submissionId": "6", "mode": "queue", "text": "x" }, + { "submissionId": "7", "mode": "queue", "text": "x" }, + { "submissionId": "8", "mode": "queue", "text": "x" }, + { "submissionId": "9", "mode": "queue", "text": "x" }, + { "submissionId": "10", "mode": "queue", "text": "x" }, + { "submissionId": "11", "mode": "queue", "text": "x" }, + { "submissionId": "12", "mode": "queue", "text": "x" }, + { "submissionId": "13", "mode": "queue", "text": "x" }, + { "submissionId": "14", "mode": "queue", "text": "x" }, + { "submissionId": "15", "mode": "queue", "text": "x" }, + { "submissionId": "16", "mode": "queue", "text": "x" }, + { "submissionId": "17", "mode": "queue", "text": "x" }, + { "submissionId": "18", "mode": "queue", "text": "x" }, + { "submissionId": "19", "mode": "queue", "text": "x" }, + { "submissionId": "20", "mode": "queue", "text": "x" }, + { "submissionId": "21", "mode": "queue", "text": "x" } + ] + ], + "textBounds": { + "note": "Each entry is `count` repetitions of `unit`; counts are Unicode code points, so an astral emoji counts once. Rejected entries must be refused and accepted entries must decode unchanged.", + "queuedText": { + "rejected": [ + { "unit": "x", "count": 0 }, + { "unit": "😀", "count": 32001 } + ], + "accepted": [ + { "unit": "x", "count": 1 }, + { "unit": "😀", "count": 32000 } + ] + }, + "app": { + "rejected": [ + { "unit": "😀", "count": 41 } + ], + "accepted": [ + { "unit": "😀", "count": 40 } + ] + } + }, + "rejectedChatActivity": [ + { "level": "verbose", "wording": "follows" }, + { "level": "quiet" }, + { "level": "quiet", "wording": "follows", "extra": true } + ], + "rejectedNullLiterals": { + "note": "An optional key sent as the literal null is refused, never read as absent. Every decoder must refuse each sample.", + "activities": [ + { "phase": "app", "app": null, "startedAt": 1760450000000 } + ], + "sessions": [ + { + "botId": "bot_fixture_01", + "epoch": "epoch_fixture_02", + "seq": 7, + "state": "running", + "interrupted": false, + "entries": [ + { "type": "message", "id": "entry_30", "role": "user", "text": "Plan dinners for this week.", "createdAt": "2026-10-14T09:00:00.000Z", "submissionId": "17" } + ], + "hasOlder": false, + "question": null, + "approval": null, + "activity": { "phase": "app", "app": "Notion", "startedAt": 1760450000000 }, + "queued": null + } + ], + "events": [ + { "protocolVersion": 1, "botId": "bot_fixture_01", "epoch": "epoch_fixture_02", "seq": 9, "type": "queued", "payload": { "queued": null } } + ], + "entries": [ + { "type": "message", "id": "entry_30", "role": "user", "text": "Plan dinners for this week.", "createdAt": "2026-10-14T09:00:00.000Z", "label": null, "submissionId": "17" } + ] + } } } diff --git a/protocol/aiden-remote/v1/openapi.json b/protocol/aiden-remote/v1/openapi.json index 85c20549a..bf4d21fe3 100644 --- a/protocol/aiden-remote/v1/openapi.json +++ b/protocol/aiden-remote/v1/openapi.json @@ -6719,7 +6719,8 @@ "runs:observe", "runs:control", "simulators:mobile", - "bot:cards" + "bot:cards", + "bot:activity" ] }, "uniqueItems": true @@ -6753,7 +6754,8 @@ "runs:observe", "runs:control", "simulators:mobile", - "bot:cards" + "bot:cards", + "bot:activity" ] }, "uniqueItems": true @@ -6876,9 +6878,9 @@ "properties": { "accepts": { "type": "array", - "description": "Negotiable capability vocabulary the device accepts: the progress grants tasks:read and agents:read, and the desktop-only simulators:control. Only additive grants are negotiable after pairing; legacy and Bot grants are never modified by this route. simulators:control is refused with capability_denied unless the paired device type is mac or linux, and with not_found when the serving Mac has no simulator feature. Contract revision 19 adds the desktop-only host:events, runs:observe and runs:control grants under the same mac-or-linux rule; a host without the host feed or run registry answers not_found. Contract revision 24 lets iphone and ipad devices negotiate the phone-scoped runs:observe and runs:control subset while the server advertises phone-run-control-v1; host:events stays desktop-only. Contract revision 26 lets iphone and ipad devices negotiate simulators:mobile, the phone simulator viewer grant, while the server advertises mobile-simulators-v1; mac and linux devices are refused it with capability_denied, and a Mac without the simulator feature answers not_found. Contract revision 27 adds bot:cards, which any device type may negotiate while the server advertises bot-durable-session-v1: it grants no authority and only opts the device into the memory_update and routine_proposal Bot session entries.", + "description": "Negotiable capability vocabulary the device accepts: the progress grants tasks:read and agents:read, and the desktop-only simulators:control. Only additive grants are negotiable after pairing; legacy and Bot grants are never modified by this route. simulators:control is refused with capability_denied unless the paired device type is mac or linux, and with not_found when the serving Mac has no simulator feature. Contract revision 19 adds the desktop-only host:events, runs:observe and runs:control grants under the same mac-or-linux rule; a host without the host feed or run registry answers not_found. Contract revision 24 lets iphone and ipad devices negotiate the phone-scoped runs:observe and runs:control subset while the server advertises phone-run-control-v1; host:events stays desktop-only. Contract revision 26 lets iphone and ipad devices negotiate simulators:mobile, the phone simulator viewer grant, while the server advertises mobile-simulators-v1; mac and linux devices are refused it with capability_denied, and a Mac without the simulator feature answers not_found. Contract revision 27 adds bot:cards, which any device type may negotiate while the server advertises bot-durable-session-v1: it grants no authority and only opts the device into the memory_update and routine_proposal Bot session entries. Contract revision 30 adds bot:activity, which any device type may negotiate while the server advertises bot-activity-v1: it grants no authority and only opts the device into the activity and queued Bot session frames and Bot detail chatActivity.", "minItems": 1, - "maxItems": 10, + "maxItems": 11, "items": { "enum": [ "tasks:read", @@ -6890,7 +6892,8 @@ "runs:observe", "runs:control", "simulators:mobile", - "bot:cards" + "bot:cards", + "bot:activity" ] }, "uniqueItems": true @@ -6933,7 +6936,8 @@ "runs:observe", "runs:control", "simulators:mobile", - "bot:cards" + "bot:cards", + "bot:activity" ] }, "uniqueItems": true @@ -9428,6 +9432,10 @@ "visionModelSelection": { "$ref": "#/components/schemas/BotModelSelection" }, + "chatActivity": { + "description": "Contract revision 30, only for devices holding bot:activity.", + "$ref": "#/components/schemas/BotChatActivity" + }, "createdAt": { "type": "string", "format": "date-time" @@ -9530,6 +9538,10 @@ }, "avatar": { "$ref": "#/components/schemas/BotSemanticAvatar" + }, + "chatActivity": { + "description": "Contract revision 30, only on hosts announcing bot-activity-v1. Absent keeps the saved choice.", + "$ref": "#/components/schemas/BotChatActivity" } }, "additionalProperties": false @@ -14440,6 +14452,11 @@ }, "interrupted": { "const": true + }, + "submissionId": { + "description": "Contract revision 30, only for devices holding bot:activity, and only on the person's own message (role user): the submission it was sent as, the same id as the send receipt and the queued list, so a phone settles its optimistic bubble exactly. Never on a routine's input (never together with label) or a question answer, and omitted when the Mac no longer knows it; a device without bot:activity never receives the key.", + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,64}$" } }, "additionalProperties": false @@ -14694,6 +14711,99 @@ "type": "null" } ] + }, + "activity": { + "description": "Contract revision 30, only for devices holding bot:activity: what the Bot is doing in its running turn, or null.", + "oneOf": [ + { + "$ref": "#/components/schemas/BotSessionActivity" + }, + { + "type": "null" + } + ] + }, + "queued": { + "description": "Contract revision 30, only for devices holding bot:activity: the person's messages waiting for the running turn.", + "type": "array", + "maxItems": 20, + "items": { + "$ref": "#/components/schemas/BotQueuedMessage" + } + } + }, + "additionalProperties": false + }, + "BotSessionActivity": { + "description": "Contract revision 30 (bot-activity-v1). Exactly these keys: never a step, detail, path, command, query or thinking. Clients render an unknown phase as working.", + "type": "object", + "required": [ + "phase", + "startedAt" + ], + "properties": { + "phase": { + "type": "string", + "pattern": "^[a-z][a-z_]{0,31}$" + }, + "app": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "startedAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "BotQueuedMessage": { + "type": "object", + "required": [ + "submissionId", + "mode", + "text" + ], + "properties": { + "submissionId": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,64}$" + }, + "mode": { + "enum": [ + "steer", + "queue" + ] + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 32000 + } + }, + "additionalProperties": false + }, + "BotChatActivity": { + "type": "object", + "required": [ + "level", + "wording" + ], + "properties": { + "level": { + "enum": [ + "quiet", + "summary", + "detailed" + ] + }, + "wording": { + "enum": [ + "follows", + "thinking" + ] } }, "additionalProperties": false @@ -14736,6 +14846,8 @@ "state", "question", "approval", + "activity", + "queued", "closed" ] }, @@ -14755,6 +14867,13 @@ "type": "string", "minLength": 1, "maxLength": 32000 + }, + "whenBusy": { + "description": "Contract revision 30, only on hosts announcing bot-activity-v1: steer joins the running turn; queue (or no value) runs after it.", + "enum": [ + "steer", + "queue" + ] } }, "additionalProperties": false diff --git a/renderer/lib/chat-message-queue.test.ts b/renderer/lib/chat-message-queue.test.ts index 12d049ac2..32bc3e379 100644 --- a/renderer/lib/chat-message-queue.test.ts +++ b/renderer/lib/chat-message-queue.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + admitRunInputOnceStarted, ChatMessageQueue, chatMessageQueue, canSteerQueuedMessage, @@ -517,3 +518,73 @@ test("messages a run cannot take as guidance are never offered to admission", as assert.equal(queue.getSnapshot().messages.length, unsteerable.length + 1); assert.equal(canSteerQueuedMessage(message("plain")), true); }); + +test("guidance sent while this window's run is still starting waits for the run to take input", async () => { + // Main answers run_not_active until Pi begins the first turn, although Stop + // and the Steer pill are live as soon as the send starts. + const answers = [ + { admitted: false, reason: "run_not_active" as const, committed: false }, + { admitted: false, reason: "run_not_active" as const, committed: false }, + { admitted: true, queue: "steer" as const, committed: true, messageId: "m1" }, + ]; + const waits: number[] = []; + const receipt = await admitRunInputOnceStarted({ + admit: async () => answers.shift()!, + live: () => true, + delay: async (ms) => { + waits.push(ms); + }, + }); + assert.deepEqual(receipt, { admitted: true, queue: "steer", committed: true, messageId: "m1" }); + assert.equal(waits.length, 2); + assert.equal(answers.length, 0); +}); + +test("guidance for a run that ended, was stopped or never starts still reports it ended", async () => { + const notActive = { admitted: false, reason: "run_not_active" as const, committed: false }; + let attempts = 0; + // The window's run settled: no retry. + assert.deepEqual( + await admitRunInputOnceStarted({ admit: async () => (attempts++, notActive), live: () => false, delay: async () => {} }), + notActive, + ); + assert.equal(attempts, 1); + + // The run ends (or Stop is pressed) while waiting. + attempts = 0; + let live = true; + assert.deepEqual( + await admitRunInputOnceStarted({ + admit: async () => (attempts++, notActive), + live: () => live, + delay: async () => { + live = false; + }, + }), + notActive, + ); + assert.equal(attempts, 1); + + // Waiting is bounded. + attempts = 0; + assert.deepEqual( + await admitRunInputOnceStarted({ admit: async () => (attempts++, notActive), live: () => true, delay: async () => {}, retryMs: [1, 1, 1] }), + notActive, + ); + assert.equal(attempts, 4); +}); + +test("only a run that has not started yet is waited for", async () => { + for (const receipt of [ + { admitted: false, reason: "capacity" as const, committed: false }, + { admitted: false, reason: "cancelled" as const, committed: false }, + { admitted: false, reason: "run_not_active" as const, committed: true, messageId: "m" }, + ]) { + let attempts = 0; + assert.deepEqual( + await admitRunInputOnceStarted({ admit: async () => (attempts++, receipt), live: () => true, delay: async () => {} }), + receipt, + ); + assert.equal(attempts, 1); + } +}); diff --git a/renderer/lib/chat-message-queue.ts b/renderer/lib/chat-message-queue.ts index adf7057ca..16672d61d 100644 --- a/renderer/lib/chat-message-queue.ts +++ b/renderer/lib/chat-message-queue.ts @@ -258,6 +258,39 @@ export function canSteerQueuedMessage(message: QueuedChatMessage): boolean { ); } +/** Pauses between admission attempts while a run is still starting (about 8.5 s in all). */ +export const RUN_INPUT_START_RETRY_MS: readonly number[] = [ + 50, 100, 200, 400, 750, 1_000, 1_000, 1_000, 1_000, 1_000, 1_000, 1_000, +]; + +/** + * Admits run input, waiting out the start of a run this window owns. Stop and + * the Steer pill are live as soon as a send begins, but Pi takes input only + * once its first turn has started; until then main answers `run_not_active`. + * While the run is still this window's live run that answer means "not yet", + * so admission is tried again. A run that ended, was stopped, or never starts + * within the bound gets the rejection as before. Nothing is written to the + * transcript by a rejected attempt, so retrying cannot duplicate guidance. + */ +export async function admitRunInputOnceStarted(input: { + admit: () => Promise; + /** The run is still this window's own, unsettled and not being stopped. */ + live: () => boolean; + delay?: (ms: number) => Promise; + retryMs?: readonly number[]; +}): Promise { + const delay = input.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const retryMs = input.retryMs ?? RUN_INPUT_START_RETRY_MS; + for (let attempt = 0; ; attempt += 1) { + const receipt = await input.admit(); + const notStartedYet = + !receipt.admitted && !receipt.committed && receipt.reason === "run_not_active"; + if (!notStartedYet || attempt >= retryMs.length || !input.live()) return receipt; + await delay(retryMs[attempt]!); + if (!input.live()) return receipt; + } +} + /** User-facing copy for a steer that main did not write to the transcript. */ export function steerRejectionMessage(reason: ChatRunInputRejectionReason | undefined): string { switch (reason) { diff --git a/renderer/lib/chat-terminal-sync.test.ts b/renderer/lib/chat-terminal-sync.test.ts index 3c2e60472..713448aad 100644 --- a/renderer/lib/chat-terminal-sync.test.ts +++ b/renderer/lib/chat-terminal-sync.test.ts @@ -17,6 +17,7 @@ import { publishObservedRunProjection, releaseSettledDetachedLifecycleStreams, rememberDetachedLifecycleStream, + rememberOwnGenerationStream, subscribeChatReadReconciliations, subscribeDetachedLifecycleStreams, subscribeChatSettlements, @@ -788,6 +789,64 @@ test("a run this document started is never mirrored over its own detached projec assert.equal(clearInactiveDetachedLifecycleChat(captureDetachedLifecycleChat(own)!, new Set()), true); }); +test("a run this document started is not mirrored back after its own terminal settles", async () => { + // Revisit a running chat, then Stop: the watcher started on revisit still + // follows the journal and reports the run's end after the terminal cleared + // its detached marker. The run is still this window's own. + const listeners = new Map void>>(); + const unsubscribe = subscribeDetachedTerminalChats( + (channel, handler) => { + const handlers = listeners.get(channel) ?? new Set(); + handlers.add(handler); + listeners.set(channel, handlers); + return () => handlers.delete(handler); + }, + () => undefined, + ); + const own = detached("own-stopped-run", "chat-own-stopped", "workspace-1"); + try { + rememberDetachedLifecycleStream(own, { content: "Partial", reasoning: "", timeline: null, artifacts: [], subagents: [] }); + for (const handler of listeners.get("chat:done") ?? []) { + handler({ streamId: own.streamId, chat: chat(own.chatId, "Partial") }); + } + assert.equal(isDetachedLifecycleChatDraining(own.chatId, own.workspaceId), false); + + assert.equal( + publishObservedRunProjection(own, { + text: "Partial", + reasoning: "", + timeline: null, + textStreaming: false, + finished: true, + }), + false, + ); + assert.equal(detachedLifecycleChatProjection(own.chatId, own.workspaceId), null); + assert.equal(isDetachedLifecycleChatDraining(own.chatId, own.workspaceId), false); + + // A run another device started in the same chat is still mirrored. + const phone = detached("phone-run-after-stop", own.chatId, own.workspaceId); + assert.equal( + publishObservedRunProjection(phone, { text: "Hi", reasoning: "", timeline: null, textStreaming: true }), + true, + ); + assert.equal(detachedLifecycleChatProjection(own.chatId, own.workspaceId)?.streamId, phone.streamId); + assert.equal(clearObservedRunProjection(own.chatId, own.workspaceId, phone.streamId), true); + } finally { + unsubscribe(); + } +}); + +test("a run this document started without detaching is never mirrored", () => { + const own = detached("own-attached-run", "chat-own-attached", "workspace-1"); + rememberOwnGenerationStream(own.streamId); + assert.equal( + publishObservedRunProjection(own, { text: "Done", reasoning: "", timeline: null, textStreaming: false, finished: true }), + false, + ); + assert.equal(isDetachedLifecycleChatDraining(own.chatId, own.workspaceId), false); +}); + test("a saved reply hides an own run, but a mirrored run only by its own reply", () => { const projection = (streamId: string, observed: boolean): DetachedLifecycleProjection => ({ ...detached(streamId), diff --git a/renderer/lib/chat-terminal-sync.ts b/renderer/lib/chat-terminal-sync.ts index 0c2c78673..75b8b3b13 100644 --- a/renderer/lib/chat-terminal-sync.ts +++ b/renderer/lib/chat-terminal-sync.ts @@ -16,6 +16,7 @@ import { } from "./composer-draft-store"; const MAX_DETACHED_STREAMS = 64; +const MAX_OWN_GENERATION_STREAMS = 256; const MAX_DETACHED_CONTENT_CHARS = 1_000_000; const MAX_DETACHED_REASONING_CHARS = 250_000; const MAX_DETACHED_ARTIFACTS = 8; @@ -25,6 +26,13 @@ const fallbackLifecycleStreams = new Map() const detachedLifecycleProjections = new Map(); /** Runs this window watches but did not start (a phone's turn), one per chat. */ const observedRunProjections = new Map(); +/** + * Runs this document started, bounded and oldest-first. A detached marker + * clears at its terminal, but a watcher started on revisit can still report + * the run's end afterwards; the run stays this window's own and is never + * mirrored back as someone else's. + */ +const ownGenerationStreams = new Set(); const fallbackSettlementInFlight = new Set(); const terminalSettlementInFlight = new Set(); const chatReadReconciliations = new Map(); @@ -301,6 +309,18 @@ export function parseChatSettlementNotification( }; } +/** Records a run this document started, so its own journal is never mirrored back to it. */ +export function rememberOwnGenerationStream(streamId: string): void { + if (!isSafeSubagentIdentifier(streamId)) return; + ownGenerationStreams.delete(streamId); + ownGenerationStreams.add(streamId); + while (ownGenerationStreams.size > MAX_OWN_GENERATION_STREAMS) { + const oldest = ownGenerationStreams.values().next().value; + if (oldest === undefined) break; + ownGenerationStreams.delete(oldest); + } +} + /** * A route transition deliberately releases every owner-bound interaction * listener. Keep bounded renderer-safe presentation state so a revisit stays @@ -317,6 +337,7 @@ export function rememberDetachedLifecycleStream( ) { return; } + rememberOwnGenerationStream(owner.streamId); detachedLifecycleStreams.delete(owner.streamId); detachedLifecycleStreams.set(owner.streamId, owner); const content = (seed?.content ?? "").slice(0, MAX_DETACHED_CONTENT_CHARS); @@ -454,7 +475,10 @@ export function publishObservedRunProjection( workspaceId: persistedChatWorkspaceId(owner.workspaceId), }; const key = chatReadReconciliationKey(normalized); - if (detachedLifecycleStreams.has(normalized.streamId)) { + if ( + detachedLifecycleStreams.has(normalized.streamId) || + ownGenerationStreams.has(normalized.streamId) + ) { return clearObservedRunProjection(normalized.chatId, normalized.workspaceId, normalized.streamId); } const previous = observedRunProjections.get(key); diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index ead90d3f7..a394c5891 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -157,6 +157,7 @@ import { parseChatReadResponse, rememberChatReadReconciliation, rememberDetachedLifecycleStream, + rememberOwnGenerationStream, } from "./chat-terminal-sync"; import type { AppCapabilities } from "./app-capabilities"; import { parseSkillCatalog, type SkillCatalogEntry } from "../shared/slash-commands"; @@ -1592,6 +1593,7 @@ export function startGeneration( messageTurnId: string, ): GenerationHandle { const streamId = messageTurnId; + rememberOwnGenerationStream(streamId); const stream = subscribeGenerationStream( streamId, { chatId: params.chatId, workspaceId: params.workspaceId }, diff --git a/renderer/main/chat-pane.tsx b/renderer/main/chat-pane.tsx index b4ff80056..e19fae879 100644 --- a/renderer/main/chat-pane.tsx +++ b/renderer/main/chat-pane.tsx @@ -21,6 +21,7 @@ import { primeInlineVisualPreview, type GuestPromptHandler } from "../components import { forkSummaryHoldsSend, type ChatForkPosition } from "../shared/chat-copy-contract"; import { ForkSummaryCard, ForkSummaryDialog } from "../components/fork-summary-card"; import { + admitRunInputOnceStarted, chatMessageQueue, committedRunInputNotice, steerQueuedMessage, @@ -1718,11 +1719,18 @@ export function ChatPane({ chatId }: { chatId: string }) { } return; } - const started = await runGeneration(messageTurnId); - // The user message crossed its durability barrier in appendMessage. - // Start rejection is surfaced by the stream callback but cannot turn - // that committed message back into an unsent composer payload. - if (!started.ok && mountedRef.current) setError(started.error.message); + // The user message crossed its durability barrier in appendMessage, so + // the send is finished here. Main may still be preparing the response: + // Stop and the Steer/Queue pill are live already, and holding this + // promise until start is acknowledged would make the composer drop + // whatever the user steers or queues in that window. A start rejection + // is surfaced as an error and cannot turn the committed message back + // into an unsent composer payload. + void runGeneration(messageTurnId).then((started) => { + if (!started.ok && mountedRef.current && chatIdRef.current === chatId) { + setError(started.error.message); + } + }); } finally { if (firstDraft) finishChatDraftSend(chatId, false); if ( @@ -1872,7 +1880,11 @@ export function ChatPane({ chatId }: { chatId: string }) { /** Admit run input and show what main committed without waiting for the run to end. */ const admitSteer = React.useCallback( async (streamId: string, text: string) => { - const receipt = await chatControl.submitInput(streamId, "steer", text); + const receipt = await admitRunInputOnceStarted({ + admit: () => chatControl.submitInput(streamId, "steer", text), + // This pane's own run can still be starting; a detached one is not retried. + live: () => generationRef.current?.streamId === streamId && !stopRequestedRef.current, + }); if (receipt.committed && receipt.messageId) { const committed = { messageId: receipt.messageId, text, createdAt: Date.now() }; qc.setQueryData(queryKeys.chat(chatId), (current) => diff --git a/renderer/main/chat-transition.test.tsx b/renderer/main/chat-transition.test.tsx index 4de00842e..85abab383 100644 --- a/renderer/main/chat-transition.test.tsx +++ b/renderer/main/chat-transition.test.tsx @@ -147,9 +147,9 @@ test("a committed append is not presented as unsent when generation start later "const handleStop = React.useCallback", ); const append = handleSend.indexOf("await chatsApi.appendMessage("); - const start = handleSend.indexOf("await runGeneration(messageTurnId)"); + const start = handleSend.indexOf("runGeneration(messageTurnId)"); assert.ok(append >= 0 && start > append); - assert.match(handleSend, /if \(!started\.ok && mountedRef\.current\) setError/u); + assert.match(handleSend, /if \(!started\.ok && mountedRef\.current[^)]*\)\s*\{\s*setError/u); assert.doesNotMatch(handleSend, /if \(!started\.ok\) throw/u); }); @@ -323,7 +323,7 @@ test("a pre-append assistant read cannot hide controls for the newer user turn", const pane = source("./chat-pane.tsx"); const send = between(pane, "const handleSend = React.useCallback(", "const handleStop = React.useCallback"); const install = send.indexOf("await installAppendedChatSnapshot(qc, chatId, updated)"); - const start = send.indexOf("await runGeneration(messageTurnId)"); + const start = send.indexOf("runGeneration(messageTurnId)"); assert.ok(install >= 0 && start > install); const queryClient = new QueryClient(); @@ -389,7 +389,7 @@ test("first-message promotion seeds the real cache before releasing draft state const seed = send.indexOf("await installAppendedChatSnapshot(qc, chatId, updated)"); const promote = send.indexOf("finishChatDraftSend(chatId, true)"); const ownerGuard = send.indexOf("(firstDraft && (!mountedRef.current || chatIdRef.current !== chatId))"); - const start = send.indexOf("await runGeneration(messageTurnId)"); + const start = send.indexOf("runGeneration(messageTurnId)"); assert.ok(seed >= 0 && promote > seed && ownerGuard > promote && start > ownerGuard); assert.match(send, /await chatsApi\.abandonTurn\(chatId, messageTurnId\)/u); assert.doesNotMatch(send, /navigate\(/u); diff --git a/scripts/check-ios-shipping-target.test.mjs b/scripts/check-ios-shipping-target.test.mjs index 974fc83e5..b3b7e9525 100644 --- a/scripts/check-ios-shipping-target.test.mjs +++ b/scripts/check-ios-shipping-target.test.mjs @@ -329,7 +329,7 @@ test("bot-first sources reuse the one reviewed chat implementation", async () => } // Bot sessions talk only to the bot-session transport; legacy Bot chats in the // product shell open the same AidenChatDetailView as every other conversation. - assert.match(botSession, /AidenBotSessionModel\(botID: botID, transport: client\)/u); + assert.match(botSession, /AidenBotSessionModel\(\s*botID: botID,\s*transport: client\b/u); assert.match( productShell, /AidenChatDetailView\(\s*coordinator: coordinator,\s*chat: presentation\.chat/u, @@ -965,7 +965,8 @@ test("Bot catalog requests stay scoped through create, advanced settings, chat, assert.doesNotMatch(advanced, /botCapabilityCatalog\(\)/u); assert.doesNotMatch(chat, /botCapabilityCatalog\(\)/u); assert.match(advanced, /botCapabilityCatalog\(botId: botID\)/u); - assert.match(advanced, /botCapabilityCatalog\(botId: attempt\.botID\)/u); + // Both the load and the Save path read the catalog scoped to this Bot. + assert.ok((advanced.match(/botCapabilityCatalog\(botId: botID\)/gu) ?? []).length >= 2, "Advanced load and Save both scope the catalog to the Bot"); assert.match(advanced, /cached\.catalog\(forBotID: botID\)/u); assert.match(chat, /botCapabilityCatalog\(botId: botID\)/u); assert.equal((chat.match(/botCapabilityCatalog\(botId: grant\.botID\)/gu) ?? []).length, 2, "file load and pre-effect revalidation use the grant owner"); diff --git a/scripts/worktree-file-io.test.mjs b/scripts/worktree-file-io.test.mjs index 2e2ff2add..130565afd 100644 --- a/scripts/worktree-file-io.test.mjs +++ b/scripts/worktree-file-io.test.mjs @@ -65,6 +65,11 @@ async function runEditor(args, input = Buffer.alloc(0), onCheckpoint = async () error => { checkpointError = error; child.kill("SIGKILL"); }, ); }); + // The editor can refuse its path and exit before reading stdin or the + // checkpoint pipe; its exit status and stdout are what tests assert, so a + // late EPIPE on those writes is contained here. + child.stdin.on("error", () => undefined); + child.stdio[4].on("error", () => undefined); child.stdin.end(input); const code = await new Promise((resolve, reject) => { child.once("error", reject); diff --git a/tests/e2e/chat-message-queue.spec.ts b/tests/e2e/chat-message-queue.spec.ts index 0066707a1..075fbd901 100644 --- a/tests/e2e/chat-message-queue.spec.ts +++ b/tests/e2e/chat-message-queue.spec.ts @@ -468,6 +468,9 @@ test("rejected and unknown Steer receipts keep the draft without replaying it", await page.getByRole("menuitemradio", { name: /^Steer/u }).click(); await expect(page.getByText(/maximum pending guidance/u)).toBeVisible(); await expect(composer).toHaveValue("Keep this guidance"); + // The closing menu hands focus back to its trigger a task later; typing + // before then can land on the trigger and reopen the menu instead. + await expect(page.getByRole("button", { name: "Choose message action" })).toBeFocused(); await composer.press("Enter"); await expect(page.getByText(/couldn't confirm the guidance/u)).toBeVisible(); await expect(composer).toHaveValue("Keep this guidance"); @@ -498,6 +501,15 @@ test("a response finishing while the queue editor is open waits for the saved ed await expect( page.getByRole("button", { name: "Stop generating", includeHidden: true }), ).toBeHidden(); + // The response finished and saved its reply; a failed one would pause the + // queue instead, which is a different scenario from waiting for the edit. + await expect( + page.getByRole("button", { name: "Fork from here", includeHidden: true }), + ).toHaveCount(1); + await expect( + page.getByRole("region", { name: "Queued messages", includeHidden: true }) + .getByRole("status", { includeHidden: true }), + ).toHaveText("1 queued"); expect( lmStudio.requests.filter((request) => lastUserText(request) === "Original queued text"), ).toHaveLength(0); @@ -631,31 +643,39 @@ test("Stop pauses queued image and text follow-ups without sending them", async await finishLmStudioOnboarding(page); lmStudio.holdCompletions!(); await aiden.app.evaluate(({ ipcMain }) => { - // Hold only the start receipt: generation can expose Stop before send settles. + // Hold the saved first message's receipt, then the start receipt, so each + // phase of a send that shows Stop early is observed deterministically. const handlers = (ipcMain as unknown as { _invokeHandlers: Map unknown>; })._invokeHandlers; - const original = handlers.get("chat:start")!; - let release: () => void = () => {}; - const gate = new Promise((resolve) => { release = resolve; }); - (globalThis as unknown as { releaseQueueStartReceipt: () => void }).releaseQueueStartReceipt = release; - handlers.set("chat:start", async (event, ...args) => { - const receipt = await original(event, ...args); - await gate; - return receipt; - }); + const hold = (channel: string, name: string) => { + const original = handlers.get(channel)!; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { release = resolve; }); + (globalThis as unknown as Record void>)[name] = release; + handlers.set(channel, async (event, ...args) => { + const receipt = await original(event, ...args); + await gate; + return receipt; + }); + }; + hold("chats:createWithFirstMessage", "releaseQueueSaveReceipt"); + hold("chat:start", "releaseQueueStartReceipt"); }); + const release = (name: string) => aiden.app.evaluate((_electron, key) => { + (globalThis as unknown as Record void>)[key]!(); + }, name); const composer = page.locator("textarea"); await composer.fill("First response for stop and resume."); await composer.press("Enter"); await expect(page.getByRole("button", { name: "Stop generating" })).toBeVisible(); const attach = page.getByRole("button", { name: "Attach files or images", exact: true }); + // Until the message is saved, a failed send restores its own payload, so + // attachment intake stays closed and cannot merge new files into it. await expect(attach).toBeDisabled(); - await aiden.app.evaluate(() => { - (globalThis as unknown as { releaseQueueStartReceipt: () => void }).releaseQueueStartReceipt(); - }); - // Stop is visible before the send receipt settles. Paste is admitted only - // after the composer reopens attachment intake, while generation continues. + await release("releaseQueueSaveReceipt"); + // Once saved, the send is settled while the response is still starting: + // intake reopens alongside Steer and Queue. await expect(attach).toBeEnabled(); await expect(page.getByRole("button", { name: "Stop generating" })).toBeVisible(); const image = ( @@ -680,6 +700,7 @@ test("Stop pauses queued image and text follow-ups without sending them", async await composer.press("Enter"); const queue = page.getByRole("region", { name: "Queued messages", exact: true }); await expect(queue.getByRole("img", { name: "Pasted image.png" })).toBeVisible(); + await release("releaseQueueStartReceipt"); await page.getByRole("button", { name: "Stop generating" }).click(); await expect(page.getByRole("button", { name: "Stop generating" })).toBeHidden(); await expect(queue.getByRole("status")).toContainText("2 queued · Paused"); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index e6f39bbf3..be8b6e0e3 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -835,6 +835,26 @@ function formatFailure(error: unknown): string { return error instanceof Error ? `${error.name}: ${error.message}` : String(error); } +const MAX_PRINTED_DEV_LOG_PROBLEMS = 40; + +/** The dev log's warn and error records, one short line each (the log is already redacted). */ +function devLogProblems(devLog: string): string[] { + const problems: string[] = []; + for (const line of devLog.split("\n")) { + let record: { at?: unknown; level?: unknown; area?: unknown; event?: unknown; fields?: Record }; + try { + record = JSON.parse(line) as typeof record; + } catch { + continue; + } + if (record.level !== "warn" && record.level !== "error") continue; + const message = typeof record.fields?.message === "string" ? record.fields.message.slice(0, 240) : ""; + const errorType = typeof record.fields?.errorType === "string" ? ` [${record.fields.errorType}]` : ""; + problems.push(` ${String(record.at)} ${String(record.level)} ${String(record.area)}/${String(record.event)}${errorType} ${message}`.trimEnd()); + } + return problems.slice(-MAX_PRINTED_DEV_LOG_PROBLEMS); +} + export const test = base.extend({ portableConfigSeed: ["lmstudio", { option: true }], workspaceSeed: [false, { option: true }], @@ -1001,10 +1021,40 @@ export const test = base.extend({ process.stderr.write(`Could not attach Electron process state: ${formatFailure(error)}\n`); } try { + // Errors the app surfaced to the user (a failed response, a paused + // queue) are often the missing half of an intermittent failure. + const window = app?.windows()[0]; + const notices = window + ? await withTimeout( + window.evaluate(() => + [...document.querySelectorAll("[role=alert], [role=status], [data-sonner-toast]")] + .map((element) => element.textContent?.replace(/\s+/gu, " ").trim().slice(0, 200) ?? "") + .filter(Boolean) + .slice(0, 20), + ), + "Reading on-screen notices", + 2_000, + ) + : []; + if (notices.length > 0) { + process.stderr.write(`On-screen notices (${testInfo.title}):\n${notices.map((notice) => ` ${notice}`).join("\n")}\n`); + } + } catch { + // A closed or crashed window has nothing more to report. + } + try { + const devLog = await readFile(path.join(rootDir, "user-data", "logs", process.env.AIDEN_E2E_RUNTIME_PROFILE === "production" ? "aiden.log" : "aiden-dev.log")); await testInfo.attach("aiden-dev-log", { - body: await readFile(path.join(rootDir, "user-data", "logs", process.env.AIDEN_E2E_RUNTIME_PROFILE === "production" ? "aiden.log" : "aiden-dev.log")), + body: devLog, contentType: "text/plain", }); + // Hosted runs keep only a sanitized receipt and the job log, where the + // reporter truncates this attachment; print the main-process warnings + // and errors so an intermittent failure still names what went wrong. + const problems = devLogProblems(devLog.toString("utf8")); + if (problems.length > 0) { + process.stderr.write(`Aiden dev-log warnings and errors (${testInfo.title}):\n${problems.join("\n")}\n`); + } } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { process.stderr.write(`Could not attach Aiden dev log: ${formatFailure(error)}\n`);