From 054d54c6c1dc6040e415ba8ddbb73e472a0bd073 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 09:15:03 -0400 Subject: [PATCH 01/83] Plan: Bot chat activity slice D (phones) Co-Authored-By: Claude Opus 5.5 --- .../2026-10-14-bot-chat-activity-d-phones.md | 5355 +++++++++++++++++ 1 file changed, 5355 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-14-bot-chat-activity-d-phones.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). From 3ba1c3f79c51d43eca96546075e6f6ecc30f80d7 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 09:48:16 -0400 Subject: [PATCH 02/83] Remote revision 30: Bot activity, queued and chatActivity wire shapes with shared fixtures Co-Authored-By: Claude Sonnet 5.5 --- .../AidenRemotePhase0Tests.swift | 2 +- .../aiden-remote-operation-contract.test.ts | 2 +- main/services/aiden-remote-pairing.test.ts | 6 +- main/services/aiden-remote-protocol.test.ts | 81 ++++++- main/services/aiden-remote-protocol.ts | 229 +++++++++++++++++- main/services/aiden-remote-router.test.ts | 2 +- .../aiden-remote/v1/fixtures/contract.json | 121 ++++++++- protocol/aiden-remote/v1/openapi.json | 126 +++++++++- 8 files changed, 543 insertions(+), 26 deletions(-) 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/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-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..9a2ec716c 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,64 @@ 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)); + } + for (const sample of raw.botActivityPrivacy.rejectedQueued) { + assert.throws(() => parseAidenRemoteBotSession({ ...raw.botActivity.session, queued: [sample] }), Error, JSON.stringify(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 })); + } + 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)); +}); diff --git a/main/services/aiden-remote-protocol.ts b/main/services/aiden-remote-protocol.ts index e54ba537d..8cdc13015 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]; @@ -1135,6 +1173,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 +1275,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 +1288,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 +1302,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 +1555,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 +2578,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 +2621,9 @@ export function parseAidenRemoteBotIdentityPatchRequest( ...(hasOwn(value, "avatar") ? { avatar: parseBotSemanticAvatar(value.avatar, true) } : {}), + ...(hasOwn(value, "chatActivity") + ? { chatActivity: parseAidenRemoteBotChatActivity(value.chatActivity) } + : {}), }; } @@ -3520,6 +3622,9 @@ export function parseAidenRemoteBotDetail(value: unknown): AidenRemoteBotDetail })(), } : {}), + ...(hasOwn(value, "chatActivity") + ? { chatActivity: parseAidenRemoteBotChatActivity(value.chatActivity) } + : {}), }; } @@ -5195,11 +5300,49 @@ 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) || 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 +5366,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 +5423,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 +5697,51 @@ 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[]; + 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), + }, + }; +} + export function parseAidenRemoteContractFixture(value: unknown): AidenRemoteContractFixture { if (!isRecord(value)) throw new Error("Aiden Remote contract fixture must be an object."); assertNoPrivateBotWireFields(value); @@ -6303,6 +6514,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 +6599,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..4274224f9 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -4187,7 +4187,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, }); diff --git a/protocol/aiden-remote/v1/fixtures/contract.json b/protocol/aiden-remote/v1/fixtures/contract.json index d5199d9af..7812bd5fc 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,122 @@ "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" } + ], + "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 } + ] } } diff --git a/protocol/aiden-remote/v1/openapi.json b/protocol/aiden-remote/v1/openapi.json index 85c20549a..c56ca316f 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 @@ -14694,6 +14706,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 +14841,8 @@ "state", "question", "approval", + "activity", + "queued", "closed" ] }, @@ -14755,6 +14862,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 From 5fe9554299cbbdb7bbf630764c82b0459e56fb81 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 10:00:02 -0400 Subject: [PATCH 03/83] Bots: shared Stop and Resume intents for the Mac and phones A Stop from the Mac or a phone now goes through one process-wide botTurnIntents emitter. A Stop that fails takes "Stopping" back (stopCancelled), and Resume is announced only from the session's onResumed, after it actually resumed. The desktop live projection listens through wireBotLiveTurnIntents, an electron-free seam tested in live-projection.test.ts. liveRunKey is exported for the Remote projector. Co-Authored-By: Claude Opus 5.5 --- main/handlers/bots.ts | 11 +- .../services/aiden-remote-bot-session.test.ts | 38 +++++ main/services/aiden-remote-bot-session.ts | 16 +- main/services/aiden-remote-service-main.ts | 2 + main/services/bot-runtime/bot-session-main.ts | 11 +- .../bot-runtime/bot-turn-intents-main.ts | 8 + main/services/bot-runtime/bot-turn-intents.ts | 77 +++++++++ .../bot-runtime/live-projection.test.ts | 146 ++++++++++++++++++ main/services/bot-runtime/live-projection.ts | 5 +- 9 files changed, 302 insertions(+), 12 deletions(-) create mode 100644 main/services/bot-runtime/bot-turn-intents-main.ts create mode 100644 main/services/bot-runtime/bot-turn-intents.ts 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..cab06ee4b 100644 --- a/main/services/aiden-remote-bot-session.test.ts +++ b/main/services/aiden-remote-bot-session.test.ts @@ -23,6 +23,7 @@ import { 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 { parseAidenRemoteBotSession, parseAidenRemoteBotSessionEntry, @@ -210,6 +211,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); diff --git a/main/services/aiden-remote-bot-session.ts b/main/services/aiden-remote-bot-session.ts index 674b817bd..047696dfa 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -86,6 +86,7 @@ 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 { stopWithIntents, type BotTurnIntents } from "./bot-runtime/bot-turn-intents.js"; import { livePartialText, projectBotTranscript } from "./bot-runtime/live-projection.js"; import { botRoutineLabel, @@ -130,6 +131,12 @@ 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`). + */ + turnIntents?: Pick; routines?: Pick; presets?: { list(): readonly BotPreset[]; @@ -1145,8 +1152,13 @@ export class AidenRemoteBotSessionService { 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)); + // 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 = () => 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-service-main.ts b/main/services/aiden-remote-service-main.ts index b84f3fe2c..59961bf14 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 { 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 { @@ -881,6 +882,7 @@ async function createRuntime(): Promise { routines: botRoutineService, questions: botQuestions, approvals: botApprovals, + turnIntents: botTurnIntents, presets: { list: () => BOT_PRESETS, // The one process-wide starter the desktop uses: a Mac tap and a diff --git a/main/services/bot-runtime/bot-session-main.ts b/main/services/bot-runtime/bot-session-main.ts index 1b6de0ebe..b61b636dc 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 { 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({ @@ -392,7 +400,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-turn-intents-main.ts b/main/services/bot-runtime/bot-turn-intents-main.ts new file mode 100644 index 000000000..671e54db2 --- /dev/null +++ b/main/services/bot-runtime/bot-turn-intents-main.ts @@ -0,0 +1,8 @@ +// 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. + +import { createBotTurnIntents } from "./bot-turn-intents.js"; + +export const botTurnIntents = createBotTurnIntents(); 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..60e219d03 --- /dev/null +++ b/main/services/bot-runtime/bot-turn-intents.ts @@ -0,0 +1,77 @@ +// 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 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); + }, + }; +} + +/** + * 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..5df63f21b 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"; @@ -869,6 +871,150 @@ 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 intents = createBotTurnIntents(); + 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"); + + 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, []); +}); + +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(); + } +}); + /** 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 }> = []; diff --git a/main/services/bot-runtime/live-projection.ts b/main/services/bot-runtime/live-projection.ts index 6df0c5ad3..4309d75ce 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; From 2c6f1f5b9bb9726fb80937c7b18037a6eb6362b3 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 10:07:42 -0400 Subject: [PATCH 04/83] Bots: turn-intent listener errors are logged; per-Bot Stop isolation test A listener that throws while hearing Stop, a failed Stop or Resume is now reported through an optional onError dep (logged as a bots warning in the process-wide emitter) instead of being swallowed, and the other listeners still hear the intent. A two-Bot case pins that a Stop for one Bot never reads Stopping on another Bot's running reply. Co-Authored-By: Claude Opus 5.5 --- .../bot-runtime/bot-turn-intents-main.ts | 6 ++- main/services/bot-runtime/bot-turn-intents.ts | 16 ++++-- .../bot-runtime/live-projection.test.ts | 49 ++++++++++++++++++- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/main/services/bot-runtime/bot-turn-intents-main.ts b/main/services/bot-runtime/bot-turn-intents-main.ts index 671e54db2..3f79b4cc3 100644 --- a/main/services/bot-runtime/bot-turn-intents-main.ts +++ b/main/services/bot-runtime/bot-turn-intents-main.ts @@ -3,6 +3,10 @@ // (`onResumed` in `bot-session-main.ts`) announce here; the desktop live // projection (`bot-session-main.ts`) and Remote projectors listen. +import { logger } from "../../platform.js"; import { createBotTurnIntents } from "./bot-turn-intents.js"; -export const botTurnIntents = createBotTurnIntents(); +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 index 60e219d03..910362759 100644 --- a/main/services/bot-runtime/bot-turn-intents.ts +++ b/main/services/bot-runtime/bot-turn-intents.ts @@ -23,15 +23,25 @@ export interface BotTurnIntents { onIntent(listener: (botId: string, intent: BotTurnIntent) => void): () => void; } -export function createBotTurnIntents(): BotTurnIntents { +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 { - // A status line that can't update must never block the Stop itself. + } 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. + } } } }, diff --git a/main/services/bot-runtime/live-projection.test.ts b/main/services/bot-runtime/live-projection.test.ts index 5df63f21b..d40e58f23 100644 --- a/main/services/bot-runtime/live-projection.test.ts +++ b/main/services/bot-runtime/live-projection.test.ts @@ -885,7 +885,10 @@ function gatedAnswer(text: string) { } test("each Stop and Resume intent reaches the desktop projection as its own call, until unwired", async () => { - const intents = createBotTurnIntents(); + 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}`), @@ -900,6 +903,7 @@ test("each Stop and Resume intent reaches the desktop projection as its own call 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."); @@ -914,6 +918,49 @@ test("each Stop and Resume intent reaches the desktop projection as its own call 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 () => { From 87b18400d39c1398f4018b72564b55e8a55337bf Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 10:21:11 -0400 Subject: [PATCH 05/83] Remote: Bot activity and queued frames for bot:activity devices; Steer and Queue Devices holding bot:activity get the turn's phase ({phase, app?, startedAt}) and the person's waiting messages in the session snapshot and as activity and queued frames; every other device gets its current state at those seqs, so seqs stay gapless and revision-29 parsers never see the new keys. A host projects them, and accepts whenBusy (steer -> steer, queue -> followUp), only with both turn intents and the Bot service's Chat activity gate. Queued inputs show only once the session classified them as the person's own (quietInputs, fail closed, throttled retry on the projector's lane); photo-only inputs are left out. Stop, a failed Stop and Resume are scoped to one run, which restarts on a new run key too. The shared derivation now holds "Picking up where it left off" until the resumed run's first live event, so a killed run's partial text no longer reads as writing after a crash, on the Mac and on phones. Co-Authored-By: Claude Opus 5.5 --- .../services/aiden-remote-bot-session.test.ts | 443 +++++++++++++++++- main/services/aiden-remote-bot-session.ts | 370 +++++++++++++-- main/services/aiden-remote-service-main.ts | 1 + .../bot-runtime/live-projection.test.ts | 56 +++ main/services/bot-runtime/live-projection.ts | 24 +- 5 files changed, 858 insertions(+), 36 deletions(-) diff --git a/main/services/aiden-remote-bot-session.test.ts b/main/services/aiden-remote-bot-session.test.ts index cab06ee4b..5a6979daf 100644 --- a/main/services/aiden-remote-bot-session.test.ts +++ b/main/services/aiden-remote-bot-session.test.ts @@ -9,6 +9,7 @@ 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"; @@ -125,6 +126,10 @@ 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])); + }, }; return { runtime, @@ -505,14 +510,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, @@ -527,6 +532,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(), }; @@ -1230,3 +1239,433 @@ 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[] = []; + 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 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 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; + 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 + await new Promise((resolve) => setTimeout(resolve, 50)); + 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: { inputs: [3] } } }, + ); + await stream.until(() => last(phasesOf(stream.frames)) !== null, "the next run"); + assert.equal(last(phasesOf(stream.frames)), "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 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(); + } +}); + +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("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(); +}); diff --git a/main/services/aiden-remote-bot-session.ts b/main/services/aiden-remote-bot-session.ts index 047696dfa..06f9ae258 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -15,6 +15,12 @@ // `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. // - Routines, connection requests and starter presets call their main services. import { randomUUID } from "node:crypto"; @@ -39,7 +45,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, @@ -86,8 +97,16 @@ 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 { stopWithIntents, type BotTurnIntents } from "./bot-runtime/bot-turn-intents.js"; -import { livePartialText, projectBotTranscript } from "./bot-runtime/live-projection.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, + liveQueuedInputIds, + liveQueuedMessages, + liveRunKey, + projectBotTranscript, +} from "./bot-runtime/live-projection.js"; import { botRoutineLabel, botRoutinePromptOf, @@ -101,11 +120,15 @@ const ctx = BACKGROUND_CONTEXT; const MAX_SESSION_JSON_BYTES = 768 * 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; @@ -113,11 +136,19 @@ 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>; } 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( @@ -135,8 +166,14 @@ export interface AidenRemoteBotSessionServiceOptions { * 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). Reported once per failing streak. */ + onReport?(botId: string, error: unknown): void; + /** Pause before a failed classification is retried; defaults to one second. */ + classifyRetryMs?: number; routines?: Pick; presets?: { list(): readonly BotPreset[]; @@ -341,6 +378,42 @@ export function projectBotSessionPartial(view: Pick): 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. + */ +export function projectBotSessionQueued( + docs: Readonly>, + personal: ReadonlySet, +): AidenRemoteBotQueuedMessage[] { + const quiet = new Set(liveQueuedInputIds(docs).filter((id) => !personal.has(id))); + const seen = new Set(); + const queued: AidenRemoteBotQueuedMessage[] = []; + for (const message of liveQueuedMessages(docs, quiet)) { + 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); + queued.push({ submissionId: message.submissionId, mode: message.mode === "steer" ? "steer" : "queue", text }); + if (queued.length === AIDEN_REMOTE_BOT_QUEUED_MAX) break; + } + return queued; +} + export function projectBotSessionState(state: BotSessionState): AidenRemoteBotSessionStateView { if (state.kind === "interrupted") { return { state: "interrupted", interrupted: true, ...(state.blocked ? { blocked: state.blocked } : {}) }; @@ -365,12 +438,30 @@ 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>; + now(): number; + retryMs: number; + report(error: unknown): void; } class BotLiveProjector { @@ -381,11 +472,35 @@ 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 (`BotLiveActivityInput.resumedFrom`). */ + private resumedFrom: unknown; + /** The session resumed: the run it publishes as running reads "Picking up where it left off". */ + private resumeRequested = false; + /** Queued submission ids classified as quiet (true) or the person's own (false). */ + private readonly inputKinds = new Map(); + private classifyFailing = false; + /** No classification before this time: a persistent failure is not retried on every frame. */ + private classifyNotBefore = 0; + private classifyRetry: ReturnType | undefined; closed = false; constructor( @@ -396,6 +511,8 @@ 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, ) {} async attach(conversation: Conversation): Promise { @@ -407,6 +524,11 @@ class BotLiveProjector { 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); }); @@ -414,23 +536,24 @@ 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. */ + snapshot(audience: Audience): AidenRemoteBotSession { 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)), + ...windowed(audience.cards ? this.entries : withoutBotCards(this.entries)), question: this.question, approval: this.approval, + ...(audience.activity && this.activityDeps ? { activity: this.activity, queued: this.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, @@ -441,14 +564,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(); } @@ -465,18 +595,18 @@ 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; + this.broadcast((audience) => ((card && !audience.cards) || (activityOnly && !audience.activity) ? noOp : text)); } /** @@ -508,6 +638,124 @@ 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); + if (next.state === "running") { + const replaced = runKey !== null && this.runKey !== null && runKey !== this.runKey; + if (previous.state !== "running" || replaced) { + this.runStartedAt = this.activityDeps!.now(); + this.stopping = false; + this.resuming = false; + } + if (this.resumeRequested) { + this.resumeRequested = false; + this.resuming = true; + this.resumedFrom = docs["pi.live"]; + } + if (this.stopRequested) this.stopping = true; + } else { + this.stopping = false; + this.resuming = false; + // 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`). Each id is + * classified once; 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. A failed + * or incomplete classification is retried after a pause, not on every frame. + */ + private async queuedFor(docs: Readonly>): Promise { + const deps = this.activityDeps!; + const ids = liveQueuedInputIds(docs); + for (const id of [...this.inputKinds.keys()]) if (!ids.includes(id)) this.inputKinds.delete(id); + const unknown = ids.filter((id) => !this.inputKinds.has(id)); + if (unknown.length > 0 && Date.now() >= this.classifyNotBefore) { + try { + const answers = await deps.classify(unknown); + for (const id of unknown) { + const quiet = answers.get(id); + if (quiet !== undefined) this.inputKinds.set(id, quiet); + } + this.classifyFailing = false; + } catch (error) { + if (!this.classifyFailing) deps.report(error); + this.classifyFailing = true; + } + if (unknown.some((id) => !this.inputKinds.has(id))) this.scheduleClassifyRetry(); + } else if (unknown.length > 0) { + this.scheduleClassifyRetry(); + } + return projectBotSessionQueued(docs, new Set(ids.filter((id) => this.inputKinds.get(id) === false))); + } + + /** One retry after the pause, on the serial lane, even if no frame arrives. */ + private scheduleClassifyRetry(): void { + const { retryMs } = this.activityDeps!; + this.classifyNotBefore = Math.max(this.classifyNotBefore, Date.now() + retryMs); + if (this.classifyRetry !== undefined || this.closed) return; + this.classifyRetry = setTimeout(() => { + this.classifyRetry = undefined; + void this.serial(async () => { + if (this.closed) return; + this.classifyNotBefore = 0; + if (this.view) await this.publish(this.view); + }); + }, retryMs); + this.classifyRetry.unref?.(); + } + private async publish(view: ConversationView): Promise { if (this.closed) return; const next = await this.resolveEntries(projectBotSessionEntries(view.entries)); @@ -515,7 +763,13 @@ class BotLiveProjector { 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)])); @@ -532,8 +786,10 @@ 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) { @@ -558,6 +814,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 { @@ -580,6 +845,7 @@ class BotLiveProjector { if (this.closed) return; this.closed = true; clearTimeout(this.idleTimer); + clearTimeout(this.classifyRetry); this.frame({ type: "closed", payload: {} }); this.onClosed(); await this.stopWatch?.().catch(() => undefined); @@ -703,6 +969,7 @@ 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; constructor(private readonly options: AidenRemoteBotSessionServiceOptions) { // A question or approval asked or settled is published in order with entries and state. @@ -711,6 +978,12 @@ export class AidenRemoteBotSessionService { }; 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 { @@ -729,6 +1002,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); @@ -1008,6 +1291,15 @@ 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), + now: () => this.options.now?.() ?? Date.now(), + retryMs: this.options.classifyRetryMs ?? CLASSIFY_RETRY_MS, + report: (error) => this.options.onReport?.(botId, error), + } + : undefined, ); await projector.attach(conversation); return projector; @@ -1025,22 +1317,29 @@ export class AidenRemoteBotSessionService { 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); } @@ -1052,7 +1351,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; @@ -1069,8 +1368,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: () => { @@ -1102,6 +1401,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(); @@ -1114,7 +1414,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( @@ -1123,7 +1428,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 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)) }; }, ); diff --git a/main/services/aiden-remote-service-main.ts b/main/services/aiden-remote-service-main.ts index 59961bf14..79eed7dd3 100644 --- a/main/services/aiden-remote-service-main.ts +++ b/main/services/aiden-remote-service-main.ts @@ -883,6 +883,7 @@ async function createRuntime(): Promise { questions: botQuestions, approvals: botApprovals, turnIntents: botTurnIntents, + onReport: (botId, error) => logger.warn("aiden-remote", `Bot ${botId} queued messages could not be classified.`, error), presets: { list: () => BOT_PRESETS, // The one process-wide starter the desktop uses: a Mac tap and a diff --git a/main/services/bot-runtime/live-projection.test.ts b/main/services/bot-runtime/live-projection.test.ts index d40e58f23..63039116b 100644 --- a/main/services/bot-runtime/live-projection.test.ts +++ b/main/services/bot-runtime/live-projection.test.ts @@ -847,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: 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", @@ -1062,6 +1073,51 @@ test("Resume is announced only for a Resume the session carried out, and before } }); +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 }> = []; diff --git a/main/services/bot-runtime/live-projection.ts b/main/services/bot-runtime/live-projection.ts index 4309d75ce..3ab7b37c4 100644 --- a/main/services/bot-runtime/live-projection.ts +++ b/main/services/bot-runtime/live-projection.ts @@ -445,6 +445,12 @@ 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. 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?: unknown; startedAt: number; } @@ -471,10 +477,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; @@ -482,6 +489,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 && JSON.stringify(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) { @@ -634,6 +645,8 @@ interface Feed { startedAt: number; stopping: boolean; resuming: boolean; + /** `pi.live` when this run's Resume began (`BotLiveActivityInput.resumedFrom`). */ + resumedFrom: unknown; /** The newest frame, so approvals, questions and Stop can re-derive the activity. */ frame: { entries: readonly EntryRecord[]; docs: Readonly> }; runActive: boolean; @@ -749,6 +762,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 } ); @@ -767,6 +781,7 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro feed.startedAt = now(); feed.stopping = false; feed.resuming = resumingBots.delete(feed.botId); + feed.resumedFrom = feed.frame.docs["pi.live"]; } async function resolveCards(botId: string, entries: BotTranscriptEntry[]): Promise { @@ -930,6 +945,7 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro startedAt: now(), stopping: false, resuming: false, + resumedFrom: undefined, frame: { entries: initial.entries, docs: initial.docs }, runActive: liveRunActive(initial.docs), runKey: liveRunKey(initial.docs), From 5105130d93863beef27b858b00d5cd4387b7e548 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 10:32:02 -0400 Subject: [PATCH 06/83] Remote: chatActivity on Bot detail and PATCH; bot-activity-v1 and the bot:activity opt-in Co-Authored-By: Claude Sonnet 5.5 --- main/services/aiden-remote-bots.test.ts | 42 +++++++++ main/services/aiden-remote-bots.ts | 22 ++++- main/services/aiden-remote-protocol.test.ts | 32 ++++++- main/services/aiden-remote-protocol.ts | 3 +- main/services/aiden-remote-router.test.ts | 94 ++++++++++++++++++- main/services/aiden-remote-router.ts | 53 +++++++++-- main/services/aiden-remote-service-main.ts | 1 + main/services/aiden-remote-state.test.ts | 10 ++ main/services/aiden-remote-state.ts | 14 +-- .../aiden-remote/v1/fixtures/contract.json | 52 ++++++++++ 10 files changed, 298 insertions(+), 25 deletions(-) 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-protocol.test.ts b/main/services/aiden-remote-protocol.test.ts index 9a2ec716c..7b35cbbfa 100644 --- a/main/services/aiden-remote-protocol.test.ts +++ b/main/services/aiden-remote-protocol.test.ts @@ -2421,15 +2421,43 @@ test("Bot activity frames carry only the §11 allow-list, and every privacy samp 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 })); } - 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 () => { diff --git a/main/services/aiden-remote-protocol.ts b/main/services/aiden-remote-protocol.ts index 8cdc13015..625fa4cea 100644 --- a/main/services/aiden-remote-protocol.ts +++ b/main/services/aiden-remote-protocol.ts @@ -5328,7 +5328,8 @@ export function parseAidenRemoteBotQueuedMessage(value: unknown): AidenRemoteBot } function parseBotQueuedList(value: unknown): AidenRemoteBotQueuedMessage[] { - if (!Array.isArray(value) || value.length > AIDEN_REMOTE_BOT_QUEUED_MAX) { + 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); diff --git a/main/services/aiden-remote-router.test.ts b/main/services/aiden-remote-router.test.ts index 4274224f9..bc264cab9 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -67,6 +67,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[] = []; @@ -210,6 +212,7 @@ async function fixture(options: { capability !== "host:events" && capability !== "runs:observe" && capability !== "runs:control" && + capability !== "bot:activity" && capability !== "bot:cards", ) ) { @@ -503,13 +506,19 @@ async function fixture(options: { }), }, bots: { + ...(options.chatActivity ? { supportsChatActivity: true } : {}), list: async () => { calls.push("bots:list"); return { bots: [botDetail], maxBots: 256 }; }, get: async (botId) => { calls.push(`bots:get:${botId}`); - return { ...botDetail, id: botId, access: { ...botAccess, botId } }; + return { + ...botDetail, + id: botId, + access: { ...botAccess, botId }, + ...(options.chatActivity ? { chatActivity: { level: "summary" as const, wording: "follows" as const } } : {}), + }; }, create: async (deviceId, key) => { calls.push(`bots:create:${deviceId}:${key}`); @@ -5058,3 +5067,86 @@ 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(); + } + } +}); + +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", + ]); +}); diff --git a/main/services/aiden-remote-router.ts b/main/services/aiden-remote-router.ts index 0a01aa1d4..02920230e 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,21 @@ 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; +} + +/** 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; +} + function advertisedServerCapabilities( device: AidenRemoteRouterAuthenticatedDevice, dependencies: AidenRemoteRouterDependencies, @@ -1484,6 +1504,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 +1851,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 +1917,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") { @@ -1979,7 +2009,7 @@ export function createAidenRemoteRequestHandler( writeJson( response, 201, - await dependencies.bots.create(device.id, key, body), + botDetailForDevice(await dependencies.bots.create(device.id, key, body), device), ); return; } @@ -2173,7 +2203,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"), + }), ); return; } @@ -2190,7 +2223,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") }, ); return; } @@ -2475,7 +2508,11 @@ 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)); + writeJson( + response, + 200, + botDetailForDevice(await dependencies.bots.get(botMatch[1]!, device.id), device), + ); return; } if (botMatch && request.method === "PATCH") { @@ -2492,11 +2529,9 @@ export function createAidenRemoteRequestHandler( writeJson( response, 200, - await dependencies.bots.updateIdentity( - botMatch[1]!, - revision, - body, - device.id, + botDetailForDevice( + 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 79eed7dd3..c82d43368 100644 --- a/main/services/aiden-remote-service-main.ts +++ b/main/services/aiden-remote-service-main.ts @@ -853,6 +853,7 @@ async function createRuntime(): Promise { throw error; } }, + chatActivity: true, idempotency, persistIdempotency: (snapshot) => operationStore.save(snapshot), notifyBotsChanged: () => ipcMain.broadcast("bots:changed", {}), diff --git a/main/services/aiden-remote-state.test.ts b/main/services/aiden-remote-state.test.ts index e91343f48..4545ff54e 100644 --- a/main/services/aiden-remote-state.test.ts +++ b/main/services/aiden-remote-state.test.ts @@ -967,3 +967,13 @@ 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"); +}); 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/protocol/aiden-remote/v1/fixtures/contract.json b/protocol/aiden-remote/v1/fixtures/contract.json index 7812bd5fc..bc9081622 100644 --- a/protocol/aiden-remote/v1/fixtures/contract.json +++ b/protocol/aiden-remote/v1/fixtures/contract.json @@ -2210,6 +2210,8 @@ { "phase": "Reading!", "startedAt": 1760450000000 }, { "phase": "thinking", "startedAt": -1 }, { "phase": "thinking", "startedAt": "1760450000000" }, + { "phase": "thinking", "startedAt": 1.5 }, + { "phase": "thinking", "startedAt": 9007199254740992 }, { "startedAt": 1760450000000 } ], "rejectedQueued": [ @@ -2217,6 +2219,56 @@ { "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" }, From 26576a28abc9bf30f3dddb858c56c172368bc1fa Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 11:09:04 -0400 Subject: [PATCH 07/83] iOS Bots: revision 30 activity wire, bot:activity opt-in, shared status words Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Bots/AidenBotSessionModel.swift | 76 +++++++++ .../Remote/AidenRemoteCoordinator.swift | 1 + ios/AidenOnTheGo/Models/AidenBot.swift | 50 +++++- ios/AidenOnTheGo/Models/AidenBotSession.swift | 142 +++++++++++++++- .../Models/AidenInstallation.swift | 2 +- .../Networking/AidenRemoteClient.swift | 16 +- .../Networking/AidenRemoteContract.swift | 7 + .../AidenBotSessionTests.swift | 155 ++++++++++++++++++ 8 files changed, 435 insertions(+), 14 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index ce4e3ef01..8c861a4a3 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -12,6 +12,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 @@ -100,6 +102,7 @@ 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" static func needsModelDetail(botName: String) -> String { "Set up an AI model so \(botName) can reply." @@ -125,6 +128,76 @@ 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 + 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" + } +} + /// One row of the durable Bot transcript. Consecutive `memory_update` entries /// collapse into one quiet "Memory updated" caption. enum AidenBotTranscriptRow: Equatable, Identifiable { @@ -304,6 +377,9 @@ final class AidenBotSessionModel { question = next case let .approval(next): approval = next + case .activity, .queued: + // Revision 30 frames: the sequence advances; the model stores them in a later change. + break case .closed: return .closed } diff --git a/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift b/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift index 2cbf2c4e9..aa5a03891 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 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/AidenBotSession.swift b/ios/AidenOnTheGo/Models/AidenBotSession.swift index 34249becf..7947ede0f 100644 --- a/ios/AidenOnTheGo/Models/AidenBotSession.swift +++ b/ios/AidenOnTheGo/Models/AidenBotSession.swift @@ -497,6 +497,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 + ) + 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 } +} + /// `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 +596,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 +612,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 +625,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 +655,14 @@ 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) { + let list = try values.decode([AidenBotQueuedMessage].self, forKey: .queued) + try AidenBotQueuedMessage.validate(list) + queued = list + } else { + queued = [] + } guard entries.count <= AidenBotSessionWire.maxEntries, Set(entries.map(\.id)).count == entries.count else { throw AidenBotContractError.invalidField("entries") @@ -579,10 +680,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 +706,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 +734,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 +787,15 @@ 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"]) + let list = try fields.decode([AidenBotQueuedMessage].self, forKey: .queued) + try AidenBotQueuedMessage.validate(list) + kind = .queued(list) case "closed": try AidenBotWire.requireOnlyKeys(payload, allowed: []) kind = .closed @@ -705,6 +823,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 +834,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..dd1e8c59a 100644 --- a/ios/AidenOnTheGo/Models/AidenInstallation.swift +++ b/ios/AidenOnTheGo/Models/AidenInstallation.swift @@ -320,7 +320,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 { diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift index 3aa370894..c5c649f4b 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,17 @@ 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` 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) + } + /// This device both may and did negotiate foreign-run observation. var canObserveForeignRuns: Bool { supportsPhoneRunControl && capabilities.contains(.runsObserve) @@ -988,7 +1002,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/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index 1243b62d2..c08e09201 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -1386,3 +1386,158 @@ 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)") + } + } + + 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) + } + } + + 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)") + } + } +} From 7ebf60fe53549958be16f237ce989067b2cc138b Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 11:14:34 -0400 Subject: [PATCH 08/83] Remote: every Bot detail route strips chatActivity for devices without bot:activity Co-Authored-By: Claude Sonnet 5.5 --- main/services/aiden-remote-router.test.ts | 51 ++++++++++++++------- main/services/aiden-remote-router.ts | 55 +++++++++++------------ main/services/aiden-remote-state.test.ts | 28 ++++++++++++ 3 files changed, 89 insertions(+), 45 deletions(-) diff --git a/main/services/aiden-remote-router.test.ts b/main/services/aiden-remote-router.test.ts index bc264cab9..e78a0ea94 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -116,6 +116,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", @@ -509,7 +513,8 @@ async function fixture(options: { ...(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}`); @@ -517,16 +522,16 @@ async function fixture(options: { ...botDetail, id: botId, access: { ...botAccess, botId }, - ...(options.chatActivity ? { chatActivity: { level: "summary" as const, wording: "follows" as const } } : {}), + ...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}`); @@ -584,7 +589,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}`); @@ -5125,10 +5130,23 @@ test("bot-activity-v1 and bot:activity are offered only while activity frames an 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 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, @@ -5139,14 +5157,17 @@ test("only a device holding bot:activity is served activity frames and a Bot's c 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"}`); + 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", "detail:plain", - "session:activity", "events:activity", "detail:activity", - ]); + 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"]); }); diff --git a/main/services/aiden-remote-router.ts b/main/services/aiden-remote-router.ts index 02920230e..096b4a622 100644 --- a/main/services/aiden-remote-router.ts +++ b/main/services/aiden-remote-router.ts @@ -1480,14 +1480,23 @@ 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( +/** + * 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, -): AidenRemoteBotDetail { - if (device.capabilities.has("bot:activity")) return detail; +): void { + if (device.capabilities.has("bot:activity")) { + writeJson(response, status, detail); + return; + } const { chatActivity: _hidden, ...visible } = detail; - return visible; + writeJson(response, status, visible); } function advertisedServerCapabilities( @@ -2006,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, - botDetailForDevice(await dependencies.bots.create(device.id, key, body), device), - ); + writeBotDetail(response, 201, await dependencies.bots.create(device.id, key, body), device); return; } if (path === "/bot-capabilities" && request.method === "GET") { @@ -2142,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; } @@ -2165,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; } @@ -2205,7 +2206,7 @@ export function createAidenRemoteRequestHandler( 200, await botSessionsOrNotFound(dependencies).session(botSessionMatch[1]!, { cards: device.capabilities.has("bot:cards"), - activity: device.capabilities.has("bot:activity"), + activity: device.capabilities.has("bot:activity") && botActivityWired(dependencies), }), ); return; @@ -2223,7 +2224,7 @@ export function createAidenRemoteRequestHandler( botSessionEventsMatch[1]!, response, admitDevice(device.id), - { cards: device.capabilities.has("bot:cards"), activity: device.capabilities.has("bot:activity") }, + { cards: device.capabilities.has("bot:cards"), activity: device.capabilities.has("bot:activity") && botActivityWired(dependencies) }, ); return; } @@ -2508,11 +2509,7 @@ export function createAidenRemoteRequestHandler( if (!dependencies.bots) { throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); } - writeJson( - response, - 200, - botDetailForDevice(await dependencies.bots.get(botMatch[1]!, device.id), device), - ); + writeBotDetail(response, 200, await dependencies.bots.get(botMatch[1]!, device.id), device); return; } if (botMatch && request.method === "PATCH") { @@ -2526,13 +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, - botDetailForDevice( - await dependencies.bots.updateIdentity(botMatch[1]!, revision, body, device.id), - device, - ), + await dependencies.bots.updateIdentity(botMatch[1]!, revision, body, device.id), + device, ); return; } diff --git a/main/services/aiden-remote-state.test.ts b/main/services/aiden-remote-state.test.ts index 4545ff54e..7db8d0f50 100644 --- a/main/services/aiden-remote-state.test.ts +++ b/main/services/aiden-remote-state.test.ts @@ -977,3 +977,31 @@ test("bot:activity is an opt-in any paired device may negotiate and keep", async 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); +}); From 044831319964f9e3597cc2ca6d9e916161d7b4a5 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 11:59:45 -0400 Subject: [PATCH 09/83] iOS Bots: status line state, Reconnecting, queued messages and Steer/Queue in the session model Optimistic send with an outbox, instant Stopping, reconcile against the Mac's entries and Queued bubbles, skeleton/connecting state and a memory-only transcript cache (R14). Folds the Task 4 review: snapshot activity/queued, bot:activity opt-in tests, one queued-list decoder, no unreachable display fallback, startedAt 2^53-1 accepted. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotSessionModel.swift | 424 +++++++++++++- ios/AidenOnTheGo/Models/AidenBotSession.swift | 17 +- .../AidenBotContractTests.swift | 35 ++ .../AidenBotSessionTests.swift | 554 +++++++++++++++++- .../AidenRemoteClientTests.swift | 46 ++ 5 files changed, 1029 insertions(+), 47 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index 8c861a4a3..ec46f9f76 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -166,7 +166,8 @@ enum AidenBotActivityPhase { 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) + // `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) } @@ -198,6 +199,105 @@ enum AidenBotElapsed { } } +/// 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) + } +} + +/// 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? + 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? + /// Waits for the next sign of a connection 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 + + /// 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 +} + +/// The last transcript seen for each Bot chat in this run of the app, so a chat +/// opens on its messages instead of waiting for the Mac (R14). Memory only. +@MainActor +final class AidenBotSessionCache { + static let shared = AidenBotSessionCache() + static let capacity = 16 + + private var entries: [String: [AidenBotSessionEntry]] = [:] + private var order: [String] = [] + + func transcript(for key: String) -> [AidenBotSessionEntry]? { entries[key] } + + func store(_ transcript: [AidenBotSessionEntry], for key: String) { + entries[key] = transcript + order.removeAll { $0 == key } + order.append(key) + while order.count > Self.capacity { entries[order.removeFirst()] = nil } + } +} + /// One row of the durable Bot transcript. Consecutive `memory_update` entries /// collapse into one quiet "Memory updated" caption. enum AidenBotTranscriptRow: Equatable, Identifiable { @@ -257,10 +357,26 @@ 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] = [] + /// Stop was pressed: the line says "Stopping" until the Mac speaks or the turn ends. + private var stopIntent: StopIntent = .none var errorMessage: String? @ObservationIgnored private var retainedKeys: [Action: UUID] = [:] - @ObservationIgnored private var retainedMessage: (text: String, key: UUID)? + @ObservationIgnored private var isFlushing = false + @ObservationIgnored private let cache: AidenBotSessionCache + @ObservationIgnored private let cacheKey: String? @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] = [:] @@ -269,9 +385,25 @@ final class AidenBotSessionModel { /// The key of a proposal answer that failed ambiguously, by proposal id. @ObservationIgnored private var proposalKeys: [String: (decision: AidenBotRoutineProposalDecision, key: UUID)] = [:] - init(botID: String, transport: any AidenBotSessionTransport) { + private enum StopIntent { case none, requested, acknowledged } + + /// Messages that keep their place while the connection is down before + /// they count as not sent. + static let maxConnectionAttempts = 3 + + /// `cacheKey` names this chat across model instances (its Mac and Bot); with + /// one, the chat opens on the transcript last seen in this run of the app. + init( + botID: String, + transport: any AidenBotSessionTransport, + cacheKey: String? = nil, + cache: AidenBotSessionCache = .shared + ) { self.botID = botID self.transport = transport + self.cacheKey = cacheKey + self.cache = cache + if let cacheKey, let cached = cache.transcript(for: cacheKey) { entries = cached } } var state: AidenBotSessionStateKind? { stateView?.state } @@ -279,7 +411,75 @@ 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 { !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 } + + 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.mode != nil || message.status == .sending): + 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 status line now (spec §12), or nil when it is hidden. + func statusLine(nowMs: Int) -> AidenBotStatusLine? { + guard activitySupported, isRunning || isAwaitingTurn 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) } + // 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 @@ -287,11 +487,13 @@ final class AidenBotSessionModel { _ = await fetchSnapshot() } - /// `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. private func fetchSnapshot() async -> Bool { do { apply(snapshot: try await transport.botSession(botId: botID)) errorMessage = nil + await flushOutbox() return true } catch is CancellationError { return false @@ -309,7 +511,14 @@ final class AidenBotSessionModel { do { for try await event in transport.botSessionEvents(botId: botID) { failures = 0 - if await apply(event) == .closed { break } + isStreamDown = false + let outcome = await apply(event) + // Any frame shows the Mac is reachable: what waits in the outbox goes out. + if outbox.contains(where: { $0.status == .sending }) { + for index in outbox.indices { outbox[index].deferred = false } + flushOutboxSoon() + } + if outcome == .closed { break } } } catch is CancellationError { return @@ -317,6 +526,9 @@ final class AidenBotSessionModel { failures += 1 } guard !Task.isCancelled else { return } + // Closed or dropped: while a turn runs the line says "Reconnecting to your Mac" + // until the next connection's first frame. + 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)) } @@ -341,6 +553,7 @@ final class AidenBotSessionModel { apply(snapshot: session, authoritative: true) return .applied } + defer { reconcileOutbox(snapshot: false) } guard let epoch, event.epoch == epoch else { return await refetch() } @@ -372,14 +585,17 @@ final class AidenBotSessionModel { // 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 case let .approval(next): approval = next - case .activity, .queued: - // Revision 30 frames: the sequence advances; the model stores them in a later change. - break + case let .activity(next): + activity = next + // The Mac's own word now stands for a Stop it has heard. + if stopIntent == .acknowledged { stopIntent = .none } + case let .queued(next): + queued = next case .closed: return .closed } @@ -404,10 +620,68 @@ 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 + activity = session.activity + queued = session.queued hasLoaded = true + if let cacheKey { cache.store(entries, for: cacheKey) } + for index in outbox.indices { outbox[index].deferred = false } + reconcileOutbox(snapshot: true) + } + + /// A turn that is no longer running takes its Stop with it, so the next + /// turn never starts at "Stopping". + private func setStateView(_ view: AidenBotSessionStateView?) { + stateView = view + if view?.state != .running { stopIntent = .none } + } + + /// Server truth replaces optimistic messages (R14). A message the Mac may + /// hold gives way to the first newer user entry with its text (the wire + /// carries no submission id on entries), and hides while the Mac's Queued + /// bubble with its submission id shows it. On a Mac that sends its queue, a + /// snapshot also drops a taken message that is neither queued nor echoed. + private func reconcileOutbox(snapshot: Bool) { + guard !outbox.isEmpty else { return } + var claimedEntries = Set() + var claimedQueued = Set(outbox.compactMap { message -> String? in + if case let .sent(id) = message.status { return id } + return nil + }) + let queuedIDs = Set(queued.map(\.submissionId)) + var kept: [AidenBotOutgoingMessage] = [] + for var message in outbox { + guard message.mayHaveReachedMac else { + kept.append(message) + continue + } + let echo = entries.first { entry in + guard case let .message(sent) = entry, sent.role == .user, + !message.baseline.contains(sent.id), !claimedEntries.contains(sent.id) else { return false } + return sent.text.trimmingCharacters(in: .whitespacesAndNewlines) == message.text + } + if let echo { + claimedEntries.insert(echo.id) + continue + } + if case let .sent(id) = message.status { + if queuedIDs.contains(id) { + message.queuedID = id + } else if snapshot, activitySupported { + // Taken, not queued and not in the transcript: nothing on the Mac holds it. + continue + } + } 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) + } + outbox = kept } // MARK: Quick-reply questions @@ -495,29 +769,105 @@ final class AidenBotSessionModel { // 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 + guard canSend, (try? AidenBotMessageRequest(text: text)) != nil else { return false } + outbox.append(AidenBotOutgoingMessage( + key: UUID(), + text: text, + mode: offersBusyModes ? mode ?? .queue : nil, + status: .sending + )) + await flushOutbox() + return true + } + + /// 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].status = .sending + outbox[index].deferred = false + outbox[index].connectionAttempts = 0 + await flushOutbox() + 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 } + } + + private func flushOutboxSoon() { + guard !isFlushing else { return } + Task { await flushOutbox() } + } + + /// Sends what waits in the outbox, oldest first, one at a time. + private func flushOutbox() async { + guard !isFlushing else { return } + isFlushing = true + defer { isFlushing = false } + while isConnected, let next = outbox.first(where: { $0.status == .sending && !$0.deferred }) { + await deliver(next.key) + } + } + + private func deliver(_ key: UUID) async { + guard let index = outbox.firstIndex(where: { $0.key == key }) else { return } + if needsModel || state == .unavailable { + outbox[index].status = .failed + return + } + let request: AidenBotMessageRequest + if let frozen = outbox[index].request { + request = frozen } else { - key = UUID() - retainedMessage = (text, key) + 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)) } 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 + setStateView(receipt.stateView) + if let current = outbox.firstIndex(where: { $0.key == key }) { + outbox[current].status = .sent(submissionId: receipt.submissionId) + outbox[current].keepsKey = false + } + reconcileOutbox(snapshot: false) } catch { - if !aidenBotSessionFailureIsAmbiguous(error) { retainedMessage = nil } - errorMessage = "That message wasn’t sent. Please try again." - return false + guard let current = outbox.firstIndex(where: { $0.key == key }) else { return } + if error is CancellationError || error is URLError { + // The connection went away: it keeps its place and key, and goes + // out on the next sign of the Mac, a few times at most. + outbox[current].connectionAttempts += 1 + if outbox[current].connectionAttempts < Self.maxConnectionAttempts { + outbox[current].deferred = true + return + } + } + outbox[current].keepsKey = aidenBotSessionFailureIsAmbiguous(error) + outbox[current].status = .failed } } @@ -548,7 +898,7 @@ final class AidenBotSessionModel { do { let receipt = try await transport.sendBotMessage(botId: botID, request: request, idempotencyKey: key) retainedRetry = nil - stateView = receipt.stateView + setStateView(receipt.stateView) return true } catch is CancellationError { return false @@ -573,32 +923,42 @@ final class AidenBotSessionModel { } } + /// 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 + 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. + @discardableResult private func perform( _ action: Action, _ 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 inFlight.insert(action) defer { inFlight.remove(action) } do { - stateView = try await request(key) + setStateView(try await request(key)) retainedKeys[action] = nil + return true } catch is CancellationError { - return + return false } catch { if !aidenBotSessionFailureIsAmbiguous(error) { retainedKeys[action] = nil } errorMessage = "That didn’t work. Please try again." + return false } } diff --git a/ios/AidenOnTheGo/Models/AidenBotSession.swift b/ios/AidenOnTheGo/Models/AidenBotSession.swift index 7947ede0f..4872d4825 100644 --- a/ios/AidenOnTheGo/Models/AidenBotSession.swift +++ b/ios/AidenOnTheGo/Models/AidenBotSession.swift @@ -572,11 +572,16 @@ struct AidenBotQueuedMessage: Codable, Equatable, Sendable { text = try AidenBotWire.requiredString(values, forKey: .text, maxLength: AidenBotSessionWire.maxMessageLength) } - /// At most 20, each id once. - static func validate(_ list: [Self]) throws { + /// 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 } @@ -657,9 +662,7 @@ struct AidenBotSession: Codable, Equatable, Sendable { approval = try values.decode(AidenRemoteBotApproval?.self, forKey: .approval) 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 + queued = try AidenBotQueuedMessage.decodeList(from: values, forKey: .queued) } else { queued = [] } @@ -793,9 +796,7 @@ struct AidenBotSessionEvent: Codable, Equatable, Sendable { 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) + kind = .queued(try AidenBotQueuedMessage.decodeList(from: fields, forKey: .queued)) case "closed": try AidenBotWire.requireOnlyKeys(payload, allowed: []) kind = .closed 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/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index c08e09201..c33a961f7 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -6,12 +6,13 @@ import XCTest 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 _sendErrors: [Error] = [] private var _connectionKeys: [UUID] = [] private var _resumeResults: [Result] = [] @@ -20,6 +21,14 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke private var _proposalResults: [Result] = [] private var resumeGate: CheckedContinuation? var holdResume = false + private var sendGate: CheckedContinuation? + /// Holds each message request until `releaseSend()`. + var holdSend = false + private var stopGate: CheckedContinuation? + /// Holds each Stop until `releaseStop()`. + var holdStop = false + private var _stopResults: [Result] = [] + private var _stopKeys: [UUID] = [] private func locked(_ body: () -> T) -> T { lock.lock() @@ -28,10 +37,14 @@ 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)) } } 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) } } + func queueStop(_ result: Result) { locked { _stopResults.append(result) } } + var isHoldingSend: Bool { locked { sendGate != nil } } + var isHoldingStop: Bool { locked { stopGate != nil } } + var stopKeys: [UUID] { locked { _stopKeys } } func queueProposalResult(_ result: Result) { locked { _proposalResults.append(result) } } @@ -44,6 +57,7 @@ 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() { @@ -54,6 +68,22 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke gate?.resume() } + func releaseSend() { + let gate = locked { () -> CheckedContinuation? in + defer { sendGate = nil } + return sendGate + } + gate?.resume() + } + + func releaseStop() { + let gate = locked { () -> CheckedContinuation? in + defer { stopGate = nil } + return stopGate + } + gate?.resume() + } + func botSession(botId: String) async throws -> AidenBotSession { let next = locked { () -> Result? in _sessionRequests += 1 @@ -64,13 +94,15 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke } func botSessionEvents(botId: String) -> AsyncThrowingStream { - let events = locked { () -> [AidenBotSessionEvent] in + let next = locked { () -> (events: [AidenBotSessionEvent], staysOpen: Bool) in _streamRequests += 1 - return _streams.isEmpty ? [] : _streams.removeFirst() + return _streams.isEmpty ? ([], false) : _streams.removeFirst() } return AsyncThrowingStream { continuation in - for event in events { continuation.yield(event) } - if events.isEmpty { + 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() @@ -86,8 +118,14 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke let error = locked { () -> Error? in _sendKeys.append(idempotencyKey) _sendTexts.append(request.text) + _sendModes.append(request.whenBusy) return _sendErrors.isEmpty ? nil : _sendErrors.removeFirst() } + if holdSend { + await withCheckedContinuation { continuation in + locked { sendGate = continuation } + } + } if let error { throw error } return try AidenRemoteJSONDecoder.decode( AidenBotMessageReceipt.self, @@ -111,7 +149,14 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke } func stopBotSession(botId: String, idempotencyKey: UUID) async throws -> AidenBotSessionStateView { - try AidenBotSessionStateView(state: .idle) + locked { _stopKeys.append(idempotencyKey) } + if holdStop { + await withCheckedContinuation { continuation in + locked { stopGate = continuation } + } + } + let next = locked { _stopResults.isEmpty ? nil : _stopResults.removeFirst() } + return try (next ?? .success(try AidenBotSessionStateView(state: .idle))).get() } func answerBotQuestion( @@ -1500,6 +1545,11 @@ final class AidenBotActivityContractTests: XCTestCase { 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 { @@ -1541,3 +1591,493 @@ final class AidenBotActivityContractTests: XCTestCase { } } } + +/// 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, []) + } + + // 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: 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 func session( + _ state: AidenBotSessionStateKind, + seq: Int = 4, + entries: [AidenBotSessionEntry] = [], + activity: AidenBotSessionActivity? = nil, + queued: [AidenBotQueuedMessage] = [] + ) throws -> AidenBotSession { + AidenBotSession( + botId: botID, + epoch: "epoch_a", + seq: seq, + stateView: try AidenBotSessionStateView(state: state), + partial: nil, + entries: entries, + hasOlder: false, + activity: activity, + queued: queued + ) + } + + private func user(_ id: String, _ text: String) -> AidenBotSessionEntry { + .message(AidenBotSessionMessage(id: id, role: .user, 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) + } + + private func model(_ transport: FakeBotSessionTransport) -> AidenBotSessionModel { + let model = AidenBotSessionModel(botID: botID, transport: transport) + model.activitySupported = true + return model + } + + private func loaded(_ transport: FakeBotSessionTransport, _ snapshot: AidenBotSession) async -> AidenBotSessionModel { + transport.queueSession(.success(snapshot)) + let model = model(transport) + await model.load() + return model + } + + 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)) + } + } + + /// 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 + } + + 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 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 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.")))) + XCTAssertEqual(bubbles("Keep it vegetarian.", in: model), 1) + XCTAssertEqual(model.outgoing, []) + } + + 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.") + let waiting = try XCTUnwrap(model.outgoing.first) + XCTAssertEqual(model.look(of: waiting), .sending, "it keeps its place") + XCTAssertEqual(transport.sendKeys.count, 1) + + transport.queueSession(.success(try session(.idle))) + 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 testMessagesThatNeverReachTheMacSayCouldntSendAndTryAgainPicksTheRightKey() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.idle)) + + // Refused outright: the Mac never took it, so Try again is a new request. + transport.failNextSend(with: try serverError(status: 400, code: "invalid_request")) + _ = await model.send("First") + let refused = try XCTUnwrap(model.outgoing.first) + XCTAssertEqual(model.look(of: refused), .failed) + let retried = await model.retrySend(refused.id) + XCTAssertTrue(retried) + XCTAssertEqual(transport.sendKeys.count, 2) + XCTAssertNotEqual(transport.sendKeys[0], transport.sendKeys[1]) + + // The Mac may have it (a server error, or the connection kept failing): Try again replays the key. + transport.failNextSend(with: try serverError(status: 500, code: "internal_error")) + _ = await model.send("Second") + let unsure = try XCTUnwrap(model.outgoing.last) + XCTAssertEqual(model.look(of: unsure), .failed) + _ = await model.retrySend(unsure.id) + XCTAssertEqual(transport.sendKeys[3], transport.sendKeys[2]) + + for _ in 0.. 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)) + } + @MainActor func testServerRefreshCanNarrowButNeverWidenDeviceGrants() throws { let keychain = AidenRemoteMemoryKeychain() From 4f0664344c6cb91dd441cd7f87a2cb17e72ee1f3 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 12:32:37 -0400 Subject: [PATCH 10/83] iOS Bots: status line, Steer/Queue pill, Queued bubbles and glancing faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bot chat draws the status line, the Steer/Queue busy pill beside Stop, the Mac's Queued bubbles and the person's own outgoing messages (Sending…, Queued, Couldn't send · Try again). It never blocks on the connection: the composer works at once, a chat with no cached transcript shows skeleton bubbles, and a quiet Connecting… hint replaces the spinner and the blocking load error. Working Bots' faces glance in Bots home, replacing the green dot. Co-Authored-By: Claude Opus 5.5 --- .../AidenBotGeneratedAvatarLifecycle.swift | 13 +- .../Bots/AidenBotSemanticAvatarView.swift | 49 ++- .../Bots/AidenBotSessionChatView.swift | 336 ++++++++++++++++-- .../Features/Bots/AidenBotsHomeView.swift | 13 +- .../AidenBotSessionTests.swift | 79 ++++ 5 files changed, 446 insertions(+), 44 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift index b1899d6c4..f2efc2c1a 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift @@ -1020,7 +1020,10 @@ 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 @State private var canonicalImage: UIImage? @State private var loadedCacheKey: AidenBotCanonicalAvatarCacheKey? @@ -1038,7 +1041,8 @@ struct AidenBotCanonicalAvatarView: View { avatar: avatar.semantic, name: name, size: size, - isDecorative: isDecorative + isDecorative: isDecorative, + working: working ) if let canonicalImage { Image(uiImage: canonicalImage) @@ -1049,6 +1053,13 @@ struct AidenBotCanonicalAvatarView: View { .accessibilityHidden(isDecorative) .accessibilityLabel(isDecorative ? "" : "\(name) Bot photo") } + // 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) + } } .frame(width: size, height: size) .task(id: loadIdentity) { diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift index fa2f05a65..d6daee5d1 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift @@ -79,11 +79,36 @@ 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) + +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 +118,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 +141,27 @@ 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) + 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) } - .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..605448516 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift @@ -24,13 +24,20 @@ struct AidenBotSessionChatView: View { /// 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: AidenStreamInputMode = .queue @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,7 +57,8 @@ 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) } } @@ -119,6 +127,21 @@ struct AidenBotSessionChatView: View { .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 + if !running { busyMode = .queue } + } + .onChange(of: coordinator.server?.supportsBotActivity == true) { _, 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 } + } } private func clearErrors() { @@ -239,12 +262,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() + } ForEach(AidenBotTranscriptRow.rows(for: model.entries)) { row in rowView(row, model: model) .id(row.id) @@ -253,6 +281,14 @@ 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) + .id("status") Color.clear.frame(height: 1).id("bottom") } .padding(.horizontal, 16) @@ -261,21 +297,17 @@ struct AidenBotSessionChatView: View { .scrollDismissesKeyboard(.interactively) .onChange(of: model.entries.count) { _, _ in proxy.scrollTo("bottom", anchor: .bottom) } .onChange(of: model.partial) { _, _ in proxy.scrollTo("bottom", anchor: .bottom) } + .onChange(of: model.queued.count) { _, _ in proxy.scrollTo("bottom", anchor: .bottom) } + .onChange(of: model.outgoing.count) { _, _ 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() + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .scrollDisabled(true) } } @@ -352,6 +384,88 @@ 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) { + Text(text) + .foregroundStyle(palette.foreground) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(palette.raised, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + .opacity(faded ? 0.55 : 1) + 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(AidenBotSessionCopy.tryAgain) { + Task { await model.retrySend(message.id) } + } + .font(.caption.weight(.semibold)) + .foregroundStyle(palette.accent) + .disabled(!canCompose) + Button { + model.discardSend(message.id) + } label: { + Image(systemName: "xmark") + .font(.caption.weight(.semibold)) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .accessibilityLabel("Remove message") + } + .buttonStyle(.plain) + } + .accessibilityElement(children: .contain) + } + } + private func assistantBubble(_ text: String, interrupted: Bool) -> some View { AidenMarkdownView(content: text) .foregroundStyle(palette.foreground) @@ -363,6 +477,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,6 +534,76 @@ struct AidenBotSessionChatView: View { .padding(.vertical, 10) } + /// 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) + .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?) { + let text = draft + draft = "" + 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. + private func busyPill(_ model: AidenBotSessionModel) -> some View { + let enabled = canCompose && 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: { + submit(model, whenBusy: busyMode) + } + .menuStyle(.button) + .buttonStyle(.plain) + .disabled(!enabled) + .accessibilityLabel(busyMode.busyActionLabel) + .accessibilityHint("Touch and hold to choose Steer or Queue") + } + private func composer(_ model: AidenBotSessionModel) -> some View { HStack(alignment: .bottom, spacing: 10) { TextField("Message", text: $draft, axis: .vertical) @@ -425,8 +612,11 @@ struct AidenBotSessionChatView: View { .padding(.horizontal, 14) .padding(.vertical, 10) .background(palette.raised, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - .disabled(!canWrite) + .disabled(!canCompose) if model.isRunning { + if model.offersBusyModes { + busyPill(model) + } Button { Task { await model.stop() } } label: { @@ -436,14 +626,11 @@ struct AidenBotSessionChatView: View { .background(palette.raised, in: Circle()) } .buttonStyle(.plain) - .disabled(!canWrite || model.inFlight.contains(.stop)) + .disabled(!canWrite || model.isStopping || model.inFlight.contains(.stop)) .accessibilityLabel("Stop") } else { Button { - let text = draft - Task { - if await model.send(text) { draft = "" } - } + submit(model, whenBusy: nil) } label: { Image(systemName: "arrow.up") .font(.body.weight(.semibold)) @@ -452,8 +639,7 @@ struct AidenBotSessionChatView: View { .background(palette.accent, in: Circle()) } .buttonStyle(.plain) - .disabled(!canWrite || !model.canSend - || draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(!canCompose || !model.canSend || draftIsEmpty) .accessibilityLabel("Send") } } @@ -463,7 +649,13 @@ struct AidenBotSessionChatView: View { 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) + // The chat opens on its last transcript from this run of the app (R14). + let model = AidenBotSessionModel( + botID: botID, + transport: client, + cacheKey: "\(context.instanceId)/\(botID)" + ) + model.activitySupported = coordinator.server?.supportsBotActivity == true self.model = model async let summaryLoad: Void = loadSummary() await model.load() @@ -482,6 +674,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 +689,99 @@ 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…" +} + +/// 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 { + @Environment(\.aidenReduceMotion) private var reduceMotion + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + AidenBotSkeletonBlock(width: 206, height: 48, radius: 20, reduceMotion: reduceMotion) + AidenBotSkeletonBlock(width: 252, height: 70, radius: 20, reduceMotion: reduceMotion) + HStack { + Spacer(minLength: 72) + AidenBotSkeletonBlock(width: 164, height: 48, radius: 20, reduceMotion: reduceMotion) + } + } + .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() + + 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) + // 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/AidenBotsHomeView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift index c937e53f0..7a568a3b1 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift @@ -585,18 +585,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) diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index c33a961f7..d9947240c 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -2081,3 +2081,82 @@ final class AidenBotSessionOptimisticTests: XCTestCase { XCTAssertTrue(otherMac.showsSkeleton) } } + +/// 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") + } +} + +/// 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 + } + } +} From 0f873b15531282e31d57b79bec6e9688816615da Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 13:13:10 -0400 Subject: [PATCH 11/83] Remote: user transcript entries carry their submissionId for bot:activity devices The person's own `message` entries (role user) now carry the optional `submissionId` they were sent as, so phones settle optimistic sends exactly. - BotSessionRuntime.placedSubmissions(botId) maps each placed input's pi.user entry to its submission id, using the same quiet-input check as quietInputs, so routine inputs and the self-intro are never listed. - The Remote projector looks placements up on its serial lane once per new pi.user entry and keeps a projector-lifetime memo of the newest 64, so reconnect snapshots still name a finished turn's send. - Only `user` transcript entries are tagged: routine inputs and question answers never are. Devices without bot:activity get every snapshot and entry frame without the key, at the same seq. - Protocol type, strict parser (user role only, wire id shape), OpenAPI, contract fixture samples and a non-holder privacy sample; iOS decodes the optional key. Co-Authored-By: Claude Opus 5.5 --- ios/AidenOnTheGo/Models/AidenBotSession.swift | 56 ++++- .../AidenBotSessionTests.swift | 30 +++ .../services/aiden-remote-bot-session.test.ts | 191 ++++++++++++++++++ main/services/aiden-remote-bot-session.ts | 103 +++++++++- main/services/aiden-remote-protocol.test.ts | 36 ++++ main/services/aiden-remote-protocol.ts | 25 ++- main/services/aiden-remote-router.test.ts | 67 ++++++ .../bot-runtime/bot-session-service.ts | 38 +++- .../aiden-remote/v1/fixtures/contract.json | 27 ++- protocol/aiden-remote/v1/openapi.json | 5 + 10 files changed, 551 insertions(+), 27 deletions(-) diff --git a/ios/AidenOnTheGo/Models/AidenBotSession.swift b/ios/AidenOnTheGo/Models/AidenBotSession.swift index 4872d4825..e949f1f7f 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,18 @@ 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 { + guard role == .user else { throw AidenBotContractError.invalidCombination("message submissionId") } + try AidenBotSessionWire.validateSubmissionID(submissionId, field: "message.submissionId") + } self = .message(AidenBotSessionMessage( id: id, role: role, @@ -249,7 +290,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 +383,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 +419,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 } @@ -562,12 +605,7 @@ struct AidenBotQueuedMessage: Codable, Equatable, Sendable { 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") } + 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) } diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index d9947240c..0653b83a7 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -1511,6 +1511,36 @@ final class AidenBotActivityContractTests: XCTestCase { } } + 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]) diff --git a/main/services/aiden-remote-bot-session.test.ts b/main/services/aiden-remote-bot-session.test.ts index 5a6979daf..b043517c0 100644 --- a/main/services/aiden-remote-bot-session.test.ts +++ b/main/services/aiden-remote-bot-session.test.ts @@ -19,6 +19,7 @@ import { projectBotSessionEntries, projectBotSessionState, redactRemoteError, + AIDEN_REMOTE_BOT_SUBMISSION_MEMO_MAX, type AidenRemoteBotSessionRuntime, } from "./aiden-remote-bot-session.js"; import type { AskUserQuestionV1 } from "../../renderer/shared/ask-user-question.js"; @@ -130,6 +131,9 @@ function fakeRuntime(initial: BotSessionState) { // 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, @@ -1669,3 +1673,190 @@ test("a host without bot-activity-v1 refuses whenBusy instead of guessing", asyn 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("the projector remembers only the newest placements, and asks once per new message", async () => { + const driven = drivenConversation([]); + const fake = fakeRuntime({ kind: "running", submissionId: "1" }); + const asked: number[] = []; + const sent = Array.from({ length: AIDEN_REMOTE_BOT_SUBMISSION_MEMO_MAX + 6 }, (_, index) => user(index + 1, `Message ${index + 1}`)); + const service = runningOn(driven, { + ...fake, + runtime: { + ...fake.runtime, + async placedSubmissions() { + asked.push(Date.now()); + return new Map(sent.map((entry) => [String(entry.id), `sub_${entry.id}`])); + }, + }, + }); + try { + await service.session(BOT_ID, { activity: true }); + const before = asked.length; + await driven.emit(sent); + await driven.emit(sent, "Work"); + await driven.emit(sent, "Working"); + assert.equal(asked.length - before, 1, "streaming frames with no new message ask nothing"); + + const bubbles = userBubbles((await service.session(BOT_ID, { activity: true })).entries); + assert.equal(bubbles.length, sent.length); + assert.deepEqual(bubbles.slice(0, 6).map(([, id]) => id), [null, null, null, null, null, null], "the oldest are forgotten"); + assert.deepEqual( + bubbles.slice(6).map(([, id]) => id), + sent.slice(6).map((entry) => `sub_${entry.id}`), + "the newest keep theirs", + ); + } finally { + await service.close(); + } +}); diff --git a/main/services/aiden-remote-bot-session.ts b/main/services/aiden-remote-bot-session.ts index 06f9ae258..0e67fdcd5 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -20,7 +20,9 @@ // `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. +// 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"; @@ -123,6 +125,13 @@ 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; +/** + * How many of the person's placed messages a projector remembers (entry → + * submission id) after their turns end; the newest are kept. Phones settle + * only their few unsent bubbles, and an older bubble without an id is matched + * by its text. + */ +export const AIDEN_REMOTE_BOT_SUBMISSION_MEMO_MAX = 64; export interface AidenRemoteBotSessionRuntime { send( @@ -142,6 +151,12 @@ export interface AidenRemoteBotSessionRuntime { * 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 { @@ -252,8 +267,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)) { @@ -263,6 +285,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, @@ -271,6 +294,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; } @@ -372,6 +396,19 @@ 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); @@ -459,6 +496,8 @@ const ACTIVITY_FRAME_TYPES: ReadonlySet = new Set(["activity", "queued"] interface ProjectorActivity { /** `AidenRemoteBotSessionRuntime.quietInputs` for this Bot. */ classify(submissionIds: readonly string[]): Promise>; + /** `AidenRemoteBotSessionRuntime.placedSubmissions` for this Bot. */ + placed(): Promise>; now(): number; retryMs: number; report(error: unknown): void; @@ -501,6 +540,15 @@ class BotLiveProjector { /** No classification before this time: a persistent failure is not retried on every frame. */ private classifyNotBefore = 0; private classifyRetry: ReturnType | undefined; + /** + * Entry id → submission id of the person's messages placed while this + * projector lived, newest last and at most `AIDEN_REMOTE_BOT_SUBMISSION_MEMO_MAX`: + * a finished turn's submission drops out of the session, but a reconnect + * snapshot still names it. + */ + private readonly submissionOf = new Map(); + /** The newest `pi.user` entry already looked up, so each message is asked about once. */ + private placedThrough = -1; closed = false; constructor( @@ -519,7 +567,9 @@ class BotLiveProjector { 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.partial = projectBotSessionPartial(watch.value); this.state = await this.readState(); this.question = this.readQuestion(); @@ -544,7 +594,7 @@ class BotLiveProjector { seq: this.seq, ...this.state, ...(this.partial !== undefined ? { partial: this.partial } : {}), - ...windowed(audience.cards ? this.entries : withoutBotCards(this.entries)), + ...windowed(entriesFor(this.entries, audience)), question: this.question, approval: this.approval, ...(audience.activity && this.activityDeps ? { activity: this.activity, queued: this.queued } : {}), @@ -606,7 +656,14 @@ class BotLiveProjector { // 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; - this.broadcast((audience) => ((card && !audience.cards) || (activityOnly && !audience.activity) ? noOp : 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; + }); } /** @@ -740,6 +797,39 @@ class BotLiveProjector { return projectBotSessionQueued(docs, new Set(ids.filter((id) => this.inputKinds.get(id) === false))); } + /** + * 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(); + } catch { + // The field is optional: without it the phone falls back to the text. + return this.submissionOf; + } + for (const [entryId, submissionId] of [...placed].sort(([left], [right]) => Number(left) - Number(right))) { + if (!WIRE_SUBMISSION_ID.test(submissionId)) continue; + this.submissionOf.delete(entryId); + this.submissionOf.set(entryId, submissionId); + } + while (this.submissionOf.size > AIDEN_REMOTE_BOT_SUBMISSION_MEMO_MAX) { + this.submissionOf.delete(this.submissionOf.keys().next().value!); + } + return this.submissionOf; + } + /** One retry after the pause, on the serial lane, even if no frame arrives. */ private scheduleClassifyRetry(): void { const { retryMs } = this.activityDeps!; @@ -758,7 +848,7 @@ class BotLiveProjector { 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(); @@ -1295,6 +1385,7 @@ export class AidenRemoteBotSessionService { this.supportsActivity ? { classify: (ids) => runtime.quietInputs(botId, ids), + placed: () => runtime.placedSubmissions(botId), now: () => this.options.now?.() ?? Date.now(), retryMs: this.options.classifyRetryMs ?? CLASSIFY_RETRY_MS, report: (error) => this.options.onReport?.(botId, error), diff --git a/main/services/aiden-remote-protocol.test.ts b/main/services/aiden-remote-protocol.test.ts index 7b35cbbfa..871b646d7 100644 --- a/main/services/aiden-remote-protocol.test.ts +++ b/main/services/aiden-remote-protocol.test.ts @@ -2460,6 +2460,42 @@ test("Bot activity frames carry only the §11 allow-list, and every privacy samp } }); +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)); + } + assert.throws( + () => parseAidenRemoteBotSessionEntry(raw.botActivityPrivacy.rejectedSubmissionIdEntries[0]), + /Only the person's message can carry a submissionId/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; diff --git a/main/services/aiden-remote-protocol.ts b/main/services/aiden-remote-protocol.ts index 625fa4cea..88d60b65a 100644 --- a/main/services/aiden-remote-protocol.ts +++ b/main/services/aiden-remote-protocol.ts @@ -1024,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"; @@ -5008,13 +5016,22 @@ 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."); } + if (hasOwn(value, "submissionId") && role !== "user") { + 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, @@ -5027,6 +5044,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": @@ -5709,6 +5727,8 @@ export interface AidenRemoteBotActivityFixture { 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 }; @@ -5718,7 +5738,7 @@ 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"], + ["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."); @@ -5731,6 +5751,7 @@ function parseBotActivityFixture(value: unknown): AidenRemoteBotActivityFixture 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 }), diff --git a/main/services/aiden-remote-router.test.ts b/main/services/aiden-remote-router.test.ts index e78a0ea94..7c26196e5 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -22,6 +22,8 @@ import { type AidenRemoteStreamInputResult, } from "./aiden-remote-protocol.js"; import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import { AidenRemoteBotSessionService } from "./aiden-remote-bot-session.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"; @@ -5171,3 +5173,68 @@ test("only a device holding bot:activity is served activity frames and a Bot's c 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"] }); +}); 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/protocol/aiden-remote/v1/fixtures/contract.json b/protocol/aiden-remote/v1/fixtures/contract.json index bc9081622..7f95c995f 100644 --- a/protocol/aiden-remote/v1/fixtures/contract.json +++ b/protocol/aiden-remote/v1/fixtures/contract.json @@ -2145,7 +2145,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" } + { "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, @@ -2162,6 +2162,7 @@ { "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", @@ -2199,6 +2200,30 @@ }, "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_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" } }, diff --git a/protocol/aiden-remote/v1/openapi.json b/protocol/aiden-remote/v1/openapi.json index c56ca316f..eb2b3b872 100644 --- a/protocol/aiden-remote/v1/openapi.json +++ b/protocol/aiden-remote/v1/openapi.json @@ -14452,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 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 From aead8c7874baf0f55fec94ab0278f7ac2b2fd361 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 13:46:19 -0400 Subject: [PATCH 12/83] iOS Bots: exact outbox reconcile, a durable outbox and instant turn controls Reconcile by submissionId (R16), with text only for entries without it, unsettled sends and queued-before-receipt; entries are claimed once per epoch. The outbox (R17) never drops an unsettled message, holds later messages behind a deferred one, survives leaving the chat (session cache) and relaunch (protected file, 24h), retries with bounded backoff while connected, is purged on unpair and access loss, and never blocks listen(). Retry, Resume, Dismiss, answers, approvals and routine proposals apply at once and roll back on failure (R18). Receipts apply only if the session didn't move; a new run clears Stop; a clean close no longer flashes Reconnecting. Co-Authored-By: Claude Opus 5.5 --- .../Bots/AidenBotSessionChatView.swift | 12 +- .../Features/Bots/AidenBotSessionModel.swift | 804 +++++++++++++----- .../Remote/AidenRemoteCoordinator.swift | 2 + .../AidenBotSessionTests.swift | 538 ++++++++++-- 4 files changed, 1080 insertions(+), 276 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift index 605448516..5b00a8ba2 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift @@ -649,18 +649,22 @@ struct AidenBotSessionChatView: View { private func start() async { guard let context = try? coordinator.requestContext(), let client = try? coordinator.remoteClient(for: context) else { return } - // The chat opens on its last transcript from this run of the app (R14). + // 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: "\(context.instanceId)/\(botID)" + cacheKey: AidenBotSessionKey(instanceId: context.instanceId, botID: botID) ) model.activitySupported = coordinator.server?.supportsBotActivity == true 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() + _ = await (summaryLoad, firstLoad) + model.release() } @MainActor diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index ec46f9f76..24516b2d7 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 @@ -224,6 +225,12 @@ struct AidenBotStatusAnnouncer { } } +/// Names one Bot chat across model instances and launches: its Mac and its Bot. +struct AidenBotSessionKey: Hashable, Sendable { + let instanceId: 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 { @@ -242,6 +249,11 @@ struct AidenBotOutgoingMessage: Equatable, Identifiable, Sendable { 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)" } @@ -252,12 +264,20 @@ struct AidenBotOutgoingMessage: Equatable, Identifiable, Sendable { fileprivate var baseline: Set = [] /// The Mac's Queued bubble for it; while that shows, this copy is hidden. fileprivate var queuedID: String? - /// Waits for the next sign of a connection before trying again. + /// 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 + /// 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 + fileprivate var submissionId: String? { + if case let .sent(id) = status { return id } + return 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 } @@ -278,23 +298,187 @@ enum AidenBotOutgoingLook: Equatable { case failed } -/// The last transcript seen for each Bot chat in this run of the app, so a chat -/// opens on its messages instead of waiting for the Mac (R14). Memory only. +/// 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 entries: [String: [AidenBotSessionEntry]] = [:] - private var order: [String] = [] + 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: String) -> [AidenBotSessionEntry]? { entries[key] } + func transcript(for key: AidenBotSessionKey) -> [AidenBotSessionEntry]? { transcripts[key] } - func store(_ transcript: [AidenBotSessionEntry], for key: String) { - entries[key] = transcript + func store(_ transcript: [AidenBotSessionEntry], for key: AidenBotSessionKey) { + transcripts[key] = transcript order.removeAll { $0 == key } order.append(key) - while order.count > Self.capacity { entries[order.removeFirst()] = nil } + 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] ?? [] + } + + func storeOutbox(_ outbox: [AidenBotOutgoingMessage], for key: AidenBotSessionKey, owner: ObjectIdentifier) { + guard owners[key] == nil || owners[key] == owner else { return } + owners[key] = owner + outboxes[key] = outbox.isEmpty ? nil : outbox + } + + /// 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, kept 24 hours, purged with the pairing. +actor AidenBotOutboxStore { + static let shared = AidenBotOutboxStore() + static let lifetime: TimeInterval = 24 * 60 * 60 + static let maxMessages = 50 + private static let maxBytes = 4_000_000 + + 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 + } + + private struct Envelope: Codable { + let version: Int + let instanceId: String + let botID: String + let messages: [Record] + } + + 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 unsent messages younger than 24 hours, oldest first. + func load(_ key: AidenBotSessionKey) -> [AidenBotOutgoingMessage] { + let url = fileURL(key) + guard let data = try? Data(contentsOf: url), + data.count <= Self.maxBytes, + let envelope = try? JSONDecoder().decode(Envelope.self, from: data), + envelope.version == 1, envelope.instanceId == key.instanceId, envelope.botID == key.botID else { + return [] + } + let cutoff = now().addingTimeInterval(-Self.lifetime) + let fresh = envelope.messages.filter { $0.createdAt > cutoff && $0.createdAt <= now().addingTimeInterval(60) } + if fresh.count != envelope.messages.count { + if fresh.isEmpty { try? fileManager.removeItem(at: url) } else { try? write(fresh, for: key) } + } + return fresh.map { record in + 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 + return message + } + } + + func save(_ messages: [AidenBotOutgoingMessage], for key: AidenBotSessionKey) { + let records = messages.suffix(Self.maxMessages).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 + ) + } + if records.isEmpty { + remove(key) + } else { + try? write(Array(records), for: key) + } + } + + func remove(_ key: AidenBotSessionKey) { + try? fileManager.removeItem(at: fileURL(key)) + } + + func purge(instanceId: String) { + 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, botID: key.botID, messages: records)) + guard data.count <= Self.maxBytes else { return } + let url = fileURL(key) + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] + ) + try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + private func fileURL(_ key: AidenBotSessionKey) -> URL { + root.appending(path: digest(key.instanceId), directoryHint: .isDirectory) + .appending(path: "\(digest(key.botID)).json") + } + + private func digest(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() } } @@ -368,42 +552,81 @@ final class AidenBotSessionModel { /// 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] = [] + 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: StopIntent = .none + 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? @ObservationIgnored private var retainedKeys: [Action: UUID] = [:] - @ObservationIgnored private var isFlushing = false @ObservationIgnored private let cache: AidenBotSessionCache - @ObservationIgnored private let cacheKey: String? + @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)] = [:] - - private enum StopIntent { case none, requested, acknowledged } - - /// Messages that keep their place while the connection is down before - /// they count as not sent. + /// 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? + @ObservationIgnored private var flushTask: Task? + @ObservationIgnored private var backoffTask: Task? + @ObservationIgnored private var restoreTask: Task? + @ObservationIgnored private var persistTask: Task? + @ObservationIgnored private var released = false + + 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 - /// `cacheKey` names this chat across model instances (its Mac and Bot); with - /// one, the chat opens on the transcript last seen in this run of the app. + /// `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: String? = nil, - cache: AidenBotSessionCache = .shared + cacheKey: AidenBotSessionKey? = nil, + cache: AidenBotSessionCache = .shared, + outboxStore: AidenBotOutboxStore? = .shared, + retryDelays: [Duration] = [.seconds(1), .seconds(4)] ) { self.botID = botID self.transport = transport self.cacheKey = cacheKey self.cache = cache - if let cacheKey, let cached = cache.transcript(for: cacheKey) { entries = cached } + self.outboxStore = cacheKey == nil ? nil : outboxStore + self.retryDelays = retryDelays + if let cacheKey { + entries = cache.transcript(for: cacheKey) ?? [] + outbox = cache.adoptOutbox(for: cacheKey, owner: ObjectIdentifier(self)).map(Self.adopted) + } + } + + /// A message another visit left keeps its key and body; what it saw then is stale. + private static func adopted(_ message: AidenBotOutgoingMessage) -> AidenBotOutgoingMessage { + var message = message + message.deferred = false + message.echoed = false + message.queuedID = nil + return message + } + + /// The chat closed: timers stop, and the unsent messages stay for the next visit. + func release() { + released = true + backoffTask?.cancel() + flushTask?.cancel() } var state: AidenBotSessionStateKind? { stateView?.state } @@ -423,7 +646,7 @@ final class AidenBotSessionModel { 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 { !isShownAsQueued($0) } } + var outgoing: [AidenBotOutgoingMessage] { outbox.filter { !$0.echoed && !isShownAsQueued($0) } } private func isShownAsQueued(_ message: AidenBotOutgoingMessage) -> Bool { guard let id = message.queuedID else { return false } @@ -435,6 +658,8 @@ final class AidenBotSessionModel { /// 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 { @@ -443,7 +668,7 @@ final class AidenBotSessionModel { 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.mode != nil || message.status == .sending): + case _ where activitySupported && isRunning && message.enqueuedWhileRunning: return .queued(message.mode ?? .queue) case .sending: return .sending @@ -470,6 +695,7 @@ final class AidenBotSessionModel { 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 } @@ -481,44 +707,49 @@ final class AidenBotSessionModel { ) } + /// Where the transcript stood; a response is applied only if it didn't move meanwhile. + private var cursor: String { "\(epoch ?? "")\u{1f}\(seq)" } + // 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 flushTask?.value } /// `GET /session`. Returns false when no snapshot was loaded. A snapshot - /// means the Mac is reachable, so the outbox goes out. + /// 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 transport.botSession(botId: botID) + // Cleared first, so what the snapshot itself reports (a purged outbox) stays. errorMessage = nil - await flushOutbox() + apply(snapshot: session) return true } catch is CancellationError { return false } catch { + if Self.isAccessLoss(error) { purgeForAccessLoss() } 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 - let outcome = await apply(event) - // Any frame shows the Mac is reachable: what waits in the outbox goes out. - if outbox.contains(where: { $0.status == .sending }) { - for index in outbox.indices { outbox[index].deferred = false } - flushOutboxSoon() - } - if outcome == .closed { break } + if await apply(event) == .closed { break } } } catch is CancellationError { return @@ -526,9 +757,9 @@ final class AidenBotSessionModel { failures += 1 } guard !Task.isCancelled else { return } - // Closed or dropped: while a turn runs the line says "Reconnecting to your Mac" - // until the next connection's first frame. - isStreamDown = true + // 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)) } @@ -553,7 +784,6 @@ final class AidenBotSessionModel { apply(snapshot: session, authoritative: true) return .applied } - defer { reconcileOutbox(snapshot: false) } guard let epoch, event.epoch == epoch else { return await refetch() } @@ -581,6 +811,7 @@ 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 @@ -592,13 +823,19 @@ final class AidenBotSessionModel { approval = next case let .activity(next): activity = next - // The Mac's own word now stands for a Stop it has heard. - if stopIntent == .acknowledged { stopIntent = .none } + // The Mac's own word now stands for a Stop or Resume it has heard, + // and a new run (another `startedAt`) has nothing to stop. + if stopIntent == .acknowledged || (next != nil && stopStartedAt != nil && next?.startedAt != stopStartedAt) { + stopIntent = .none + } + if resumeIntent == .acknowledged { resumeIntent = .none } case let .queued(next): queued = next case .closed: return .closed } + reconcileOutbox(snapshot: false) + scheduleFlush() return .applied } @@ -627,61 +864,259 @@ final class AidenBotSessionModel { queued = session.queued hasLoaded = true if let cacheKey { cache.store(entries, for: cacheKey) } - for index in outbox.indices { outbox[index].deferred = false } 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 with it, so the next - /// turn never starts at "Stopping". + /// 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 - if view?.state != .running { stopIntent = .none } + if view?.state != .running { + stopIntent = .none + resumeIntent = .none + } + if view?.blocked == .accessChanged, !wasBlocked { purgeForAccessLoss() } } - /// Server truth replaces optimistic messages (R14). A message the Mac may - /// hold gives way to the first newer user entry with its text (the wire - /// carries no submission id on entries), and hides while the Mac's Queued - /// bubble with its submission id shows it. On a Mac that sends its queue, a - /// snapshot also drops a taken message that is neither queued nor echoed. + // 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 snapshot also + /// drops a taken message that is neither queued nor echoed. private func reconcileOutbox(snapshot: Bool) { + if claimsEpoch != epoch { + claimsEpoch = epoch + claimedEntries = [] + } guard !outbox.isEmpty else { return } - var claimedEntries = Set() - var claimedQueued = Set(outbox.compactMap { message -> String? in - if case let .sent(id) = message.status { return id } - return nil - }) + 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 } - let echo = entries.first { entry in - guard case let .message(sent) = entry, sent.role == .user, - !message.baseline.contains(sent.id), !claimedEntries.contains(sent.id) else { return false } - return sent.text.trimmingCharacters(in: .whitespacesAndNewlines) == message.text - } - if let echo { - claimedEntries.insert(echo.id) - continue + if !message.echoed, let echo = echoEntry(for: message, owned: owned) { + claimedEntries.insert(echo) + guard message.status == .sending else { continue } + message.echoed = true } - if case let .sent(id) = message.status { - if queuedIDs.contains(id) { - message.queuedID = id - } else if snapshot, activitySupported { - // Taken, not queued and not in the transcript: nothing on the Mac holds it. - continue + if !message.echoed { + if let id = message.submissionId { + if queuedIDs.contains(id) { + message.queuedID = id + } else if snapshot, activitySupported { + // Taken, not queued and not in the transcript: nothing on the Mac holds it. + continue + } + } 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 } - } 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) } - outbox = kept + if kept != outbox { outbox = kept } + } + + /// The unclaimed user entry that stands for `message`, if any. + private func echoEntry(for message: AidenBotOutgoingMessage, owned: Set) -> String? { + var byText: String? + 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.id } + 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. + if message.submissionId != nil || owned.contains(theirs) { continue } + } + byText = sent.id + } + return byText + } + + // MARK: The outbox + + private func outboxDidChange() { + guard let cacheKey, !released else { return } + cache.storeOutbox(outbox, for: cacheKey, owner: ObjectIdentifier(self)) + guard let outboxStore else { return } + let snapshot = outbox + let previous = persistTask + persistTask = Task { + await previous?.value + await outboxStore.save(snapshot, for: cacheKey) + } + } + + /// Adds what a previous launch left on disk, once, before the first snapshot sends it. + private func restoreOutbox() async { + guard let cacheKey, let outboxStore else { return } + if restoreTask == nil { + restoreTask = Task { [weak self] in + let stored = await outboxStore.load(cacheKey) + guard let self, !stored.isEmpty else { return } + let known = Set(self.outbox.map(\.key)) + let added = stored.filter { !known.contains($0.key) }.map(Self.adopted) + if !added.isEmpty { + self.outbox = (added + self.outbox).sorted { $0.createdAt < $1.createdAt } + } + } + } + await restoreTask?.value + } + + /// 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() + } + + private func scheduleFlush() { + guard !released, flushTask == nil, nextToDeliver() != nil else { return } + flushTask = Task { [weak self] in await self?.flushOutbox() } + } + + /// 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 + } + + 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 { + outbox[index].status = .failed + outbox[index].echoed = false + 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 mark = cursor + inFlight.insert(.send) + if isRetry { inFlight.insert(.retry) } + defer { + inFlight.remove(.send) + inFlight.remove(.retry) + } + do { + let receipt = try await transport.sendBotMessage(botId: botID, request: request, idempotencyKey: key) + if cursor == mark { setStateView(receipt.stateView) } + guard let current = outbox.firstIndex(where: { $0.key == key }) else { return } + 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 leaves the message as it was for the next visit. + guard !released, let current = outbox.firstIndex(where: { $0.key == key }) else { return } + if Self.isAccessLoss(error) { + purgeForAccessLoss() + return + } + guard aidenBotSessionFailureIsAmbiguous(error) else { + // Refused outright: it never reached the Mac. + outbox[current].status = .failed + outbox[current].keepsKey = false + outbox[current].echoed = false + 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 { + // The Mac's transcript already shows it. + outbox.remove(at: current) + } else { + outbox[current].status = .failed + } + } + } + + 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() + } + } + + /// 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() + outbox = [] + if let cacheKey { + cache.purge(cacheKey) + if let outboxStore { + let previous = persistTask + persistTask = Task { + await previous?.value + await outboxStore.remove(cacheKey) + } + } + } + if hadUnsent { errorMessage = "That message wasn’t sent. Please try again." } } // MARK: Quick-reply questions @@ -690,11 +1125,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 @@ -702,6 +1139,8 @@ final class AidenBotSessionModel { key = UUID() retainedQuestion = (waitId, response, key) } + let mark = cursor + question = nil inFlight.insert(.answerQuestion) defer { inFlight.remove(.answerQuestion) } do { @@ -712,10 +1151,9 @@ final class AidenBotSessionModel { idempotencyKey: key ) retainedQuestion = nil - if question?.waitId == waitId { question = nil } - } catch is CancellationError { - return } catch { + if question == nil, cursor == mark { question = shown } + if error is CancellationError { return } if !aidenBotSessionFailureIsAmbiguous(error) { retainedQuestion = nil } errorMessage = "That answer wasn’t sent. Please try again." } @@ -727,11 +1165,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 } @@ -743,6 +1182,8 @@ final class AidenBotSessionModel { key = UUID() retainedApproval = (waitId, decision, key) } + let mark = cursor + approval = nil inFlight.insert(.answerApproval) defer { inFlight.remove(.answerApproval) } do { @@ -753,16 +1194,14 @@ final class AidenBotSessionModel { 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 approval == nil, cursor == mark { approval = current } + if error is CancellationError { return } errorMessage = "That answer wasn’t sent. Please try again." } } @@ -780,14 +1219,22 @@ final class AidenBotSessionModel { func send(_ rawText: String, whenBusy mode: AidenStreamInputMode? = nil) async -> Bool { let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) guard canSend, (try? AidenBotMessageRequest(text: text)) != nil else { return false } + enqueue(text, mode: offersBusyModes ? mode ?? .queue : nil, retryOf: nil) + await flushTask?.value + return true + } + + private func enqueue(_ text: String, mode: AidenStreamInputMode?, retryOf: String?) { outbox.append(AidenBotOutgoingMessage( key: UUID(), text: text, - mode: offersBusyModes ? mode ?? .queue : nil, + mode: mode, + enqueuedWhileRunning: isRunning, + retryOf: retryOf, + createdAt: Date(), status: .sending )) - await flushOutbox() - return true + scheduleFlush() } /// Try again on a message that couldn't be sent. A message the Mac may hold @@ -802,7 +1249,8 @@ final class AidenBotSessionModel { outbox[index].status = .sending outbox[index].deferred = false outbox[index].connectionAttempts = 0 - await flushOutbox() + scheduleFlush() + await flushTask?.value return true } @@ -811,114 +1259,50 @@ final class AidenBotSessionModel { outbox.removeAll { $0.id == id && $0.status == .failed } } - private func flushOutboxSoon() { - guard !isFlushing else { return } - Task { await flushOutbox() } - } - - /// Sends what waits in the outbox, oldest first, one at a time. - private func flushOutbox() async { - guard !isFlushing else { return } - isFlushing = true - defer { isFlushing = false } - while isConnected, let next = outbox.first(where: { $0.status == .sending && !$0.deferred }) { - await deliver(next.key) - } - } - - private func deliver(_ key: UUID) async { - guard let index = outbox.firstIndex(where: { $0.key == key }) else { return } - if needsModel || state == .unavailable { - outbox[index].status = .failed - 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)) - } - inFlight.insert(.send) - defer { inFlight.remove(.send) } - do { - let receipt = try await transport.sendBotMessage(botId: botID, request: request, idempotencyKey: key) - setStateView(receipt.stateView) - if let current = outbox.firstIndex(where: { $0.key == key }) { - outbox[current].status = .sent(submissionId: receipt.submissionId) - outbox[current].keepsKey = false - } - reconcileOutbox(snapshot: false) - } catch { - guard let current = outbox.firstIndex(where: { $0.key == key }) else { return } - if error is CancellationError || error is URLError { - // The connection went away: it keeps its place and key, and goes - // out on the next sign of the Mac, a few times at most. - outbox[current].connectionAttempts += 1 - if outbox[current].connectionAttempts < Self.maxConnectionAttempts { - outbox[current].deferred = true - return - } - } - outbox[current].keepsKey = aidenBotSessionFailureIsAmbiguous(error) - outbox[current].status = .failed - } - } - - /// The newest entry when it is a failed turn that can be sent again. + /// 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 - setStateView(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) } + enqueue(text, mode: offersBusyModes ? .queue : nil, retryOf: turn.id) + await flushTask?.value + 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) } } @@ -927,6 +1311,7 @@ final class AidenBotSessionModel { func stop() async { 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) } @@ -937,25 +1322,32 @@ final class AidenBotSessionModel { } } - /// 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 -> Bool { guard !inFlight.contains(action) else { return false } let key = retainedKeys[action] ?? UUID() retainedKeys[action] = key + let previous = stateView + let mark = cursor + if let optimistic { stateView = optimistic } inFlight.insert(action) defer { inFlight.remove(action) } do { - setStateView(try await request(key)) + let view = try await request(key) + if cursor == mark { setStateView(view) } retainedKeys[action] = nil return true - } catch is CancellationError { - return false } catch { + if optimistic != nil, cursor == mark { stateView = previous } + if error is CancellationError { return false } if !aidenBotSessionFailureIsAmbiguous(error) { retainedKeys[action] = nil } errorMessage = "That didn’t work. Please try again." return false @@ -964,10 +1356,11 @@ final class AidenBotSessionModel { // 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), @@ -981,6 +1374,17 @@ 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 { @@ -995,9 +1399,11 @@ final class AidenBotSessionModel { 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. diff --git a/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift b/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift index aa5a03891..b57929161 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift @@ -994,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/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index 0653b83a7..5001e4c0f 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -1,6 +1,33 @@ import XCTest @testable import AidenOnTheGo +/// Holds a scripted request until the test lets it go. +private final class TestGate: @unchecked Sendable { + private let lock = NSLock() + private var holding = false + private var waiter: CheckedContinuation? + + var isHolding: Bool { lock.withLock { waiter != nil } } + + func hold() { lock.withLock { holding = true } } + + func pass() async { + guard lock.withLock({ holding }) else { return } + await withCheckedContinuation { continuation in + lock.withLock { waiter = continuation } + } + } + + func release() { + let waiting = lock.withLock { () -> CheckedContinuation? in + holding = false + defer { waiter = nil } + return waiter + } + 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 { @@ -29,6 +56,10 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke var holdStop = false private var _stopResults: [Result] = [] private var _stopKeys: [UUID] = [] + private var _receiptIDs: [String] = [] + private var _dismissResults: [Result] = [] + let dismissGate = TestGate() + let proposalGate = TestGate() private func locked(_ body: () -> T) -> T { lock.lock() @@ -43,6 +74,10 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke func failNextSend(with error: Error) { locked { _sendErrors.append(error) } } func queueStop(_ result: Result) { locked { _stopResults.append(result) } } var isHoldingSend: Bool { locked { sendGate != nil } } + var isHoldingResume: Bool { locked { resumeGate != nil } } + /// 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 { locked { stopGate != nil } } var stopKeys: [UUID] { locked { _stopKeys } } func queueProposalResult(_ result: Result) { @@ -127,9 +162,10 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke } } 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) ) } @@ -145,7 +181,9 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke } 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 { @@ -203,6 +241,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() } @@ -550,25 +589,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 { @@ -1352,6 +1397,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 @@ -1390,6 +1436,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 @@ -1407,6 +1454,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 @@ -1834,15 +1882,23 @@ final class AidenBotSessionActivityTests: XCTestCase { } } -/// R14: every phone Bot action applies at once and reconciles with the Mac later. +/// 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] = [] @@ -1851,7 +1907,7 @@ final class AidenBotSessionOptimisticTests: XCTestCase { botId: botID, epoch: "epoch_a", seq: seq, - stateView: try AidenBotSessionStateView(state: state), + stateView: try AidenBotSessionStateView(state: state, blocked: blocked), partial: nil, entries: entries, hasOlder: false, @@ -1860,27 +1916,59 @@ final class AidenBotSessionOptimisticTests: XCTestCase { ) } - private func user(_ id: String, _ text: String) -> AidenBotSessionEntry { - .message(AidenBotSessionMessage(id: id, role: .user, text: text, createdAt: nil, label: nil, interrupted: false)) + 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) } - private func model(_ transport: FakeBotSessionTransport) -> AidenBotSessionModel { - let model = AidenBotSessionModel(botID: botID, transport: transport) - model.activitySupported = true + /// 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)] + ) -> AidenBotSessionModel { + let model = AidenBotSessionModel( + botID: botID, + transport: transport, + cacheKey: key, + cache: cache ?? AidenBotSessionCache(), + outboxStore: store, + retryDelays: retryDelays + ) + model.activitySupported = supported return model } - private func loaded(_ transport: FakeBotSessionTransport, _ snapshot: AidenBotSession) async -> AidenBotSessionModel { + private func loaded( + _ transport: FakeBotSessionTransport, + _ snapshot: AidenBotSession, + supported: Bool = true, + retryDelays: [Duration] = [.seconds(60)] + ) async -> AidenBotSessionModel { transport.queueSession(.success(snapshot)) - let model = model(transport) + 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, @@ -1897,6 +1985,14 @@ final class AidenBotSessionOptimisticTests: XCTestCase { } } + 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 @@ -1906,6 +2002,8 @@ final class AidenBotSessionOptimisticTests: XCTestCase { 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)) @@ -1925,6 +2023,21 @@ final class AidenBotSessionOptimisticTests: XCTestCase { 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")])) @@ -1945,6 +2058,65 @@ final class AidenBotSessionOptimisticTests: XCTestCase { 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))) @@ -1966,11 +2138,28 @@ final class AidenBotSessionOptimisticTests: XCTestCase { _ = 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.")))) + _ = 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, []) } + 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) @@ -2010,63 +2199,165 @@ final class AidenBotSessionOptimisticTests: XCTestCase { let model = await loaded(transport, try session(.idle)) transport.failNextSend(with: URLError(.networkConnectionLost)) _ = await model.send("Plan my week.") - let waiting = try XCTUnwrap(model.outgoing.first) - XCTAssertEqual(model.look(of: waiting), .sending, "it keeps its place") + 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))) + 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 testMessagesThatNeverReachTheMacSayCouldntSendAndTryAgainPicksTheRightKey() async throws { + func testWhileConnectedAMessageRetriesAFewTimesUnderItsKeyAndThenSaysSo() async throws { let transport = FakeBotSessionTransport() - let model = await loaded(transport, try session(.idle)) + let model = await loaded(transport, try session(.idle), retryDelays: [.milliseconds(20)]) + for _ in 0.. Date: Wed, 14 Oct 2026 13:56:41 -0400 Subject: [PATCH 13/83] Remote: submissionId tags are sticky while an entry is in view A bounded 64-entry memo evicted old ids, so the next publish re-sent the old bubble as an entry frame to every device; phones that no longer held it appended a ghost bubble at the bottom, shipped revision-29 phones too. - The memo is keyed by entry id and pruned only when the entry leaves the session window (either audience's), so it is bounded by the window. - A tag change alone is never an entry frame: a bubble is tagged when it first shows and only loses the tag once it is out of view. - A failed placement lookup is reported through the projector's report. - The parser (TS and iOS) refuses submissionId together with label, with a rejected fixture sample. - Tests: more than a window of messages never re-sends an old bubble and every bubble in view keeps its id; a reported lookup failure; a real durable steer and follow-up placed mid-turn, two at one boundary, each take their queued id in the publish that drops them from Queued. Co-Authored-By: Claude Opus 5.5 --- ios/AidenOnTheGo/Models/AidenBotSession.swift | 5 +- .../services/aiden-remote-bot-session.test.ts | 171 ++++++++++++++++-- main/services/aiden-remote-bot-session.ts | 56 +++--- main/services/aiden-remote-protocol.test.ts | 10 +- main/services/aiden-remote-protocol.ts | 3 +- main/services/aiden-remote-service-main.ts | 2 +- .../aiden-remote/v1/fixtures/contract.json | 1 + protocol/aiden-remote/v1/openapi.json | 2 +- 8 files changed, 202 insertions(+), 48 deletions(-) diff --git a/ios/AidenOnTheGo/Models/AidenBotSession.swift b/ios/AidenOnTheGo/Models/AidenBotSession.swift index e949f1f7f..ffc6698df 100644 --- a/ios/AidenOnTheGo/Models/AidenBotSession.swift +++ b/ios/AidenOnTheGo/Models/AidenBotSession.swift @@ -276,7 +276,10 @@ enum AidenBotSessionEntry: Codable, Equatable, Identifiable, Sendable { } let submissionId = try AidenBotWire.optional(String.self, from: values, forKey: .submissionId) if let submissionId { - guard role == .user else { throw AidenBotContractError.invalidCombination("message 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( diff --git a/main/services/aiden-remote-bot-session.test.ts b/main/services/aiden-remote-bot-session.test.ts index b043517c0..2dbe66f94 100644 --- a/main/services/aiden-remote-bot-session.test.ts +++ b/main/services/aiden-remote-bot-session.test.ts @@ -19,7 +19,6 @@ import { projectBotSessionEntries, projectBotSessionState, redactRemoteError, - AIDEN_REMOTE_BOT_SUBMISSION_MEMO_MAX, type AidenRemoteBotSessionRuntime, } from "./aiden-remote-bot-session.js"; import type { AskUserQuestionV1 } from "../../renderer/shared/ask-user-question.js"; @@ -27,6 +26,7 @@ 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 { + AIDEN_REMOTE_BOT_SESSION_MAX_ENTRIES, parseAidenRemoteBotSession, parseAidenRemoteBotSessionEntry, parseAidenRemoteBotSessionEvent, @@ -1825,38 +1825,173 @@ test("a device without bot:activity gets each tagged bubble without its id, at t } }); -test("the projector remembers only the newest placements, and asks once per new message", async () => { +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" }); - const asked: number[] = []; - const sent = Array.from({ length: AIDEN_REMOTE_BOT_SUBMISSION_MEMO_MAX + 6 }, (_, index) => user(index + 1, `Message ${index + 1}`)); + let asked = 0; + let current: RawEntry[] = []; const service = runningOn(driven, { ...fake, runtime: { ...fake.runtime, async placedSubmissions() { - asked.push(Date.now()); - return new Map(sent.map((entry) => [String(entry.id), `sub_${entry.id}`])); + 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 service.session(BOT_ID, { activity: true }); - const before = asked.length; - await driven.emit(sent); - await driven.emit(sent, "Work"); - await driven.emit(sent, "Working"); - assert.equal(asked.length - before, 1, "streaming frames with no new message ask nothing"); + 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"); - const bubbles = userBubbles((await service.session(BOT_ID, { activity: true })).entries); - assert.equal(bubbles.length, sent.length); - assert.deepEqual(bubbles.slice(0, 6).map(([, id]) => id), [null, null, null, null, null, null], "the oldest are forgotten"); + 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( - bubbles.slice(6).map(([, id]) => id), - sent.slice(6).map((entry) => `sub_${entry.id}`), - "the newest keep theirs", + 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); + } 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([["2", "sub_2"]]); + }, + }, + }, + { onReport: (_botId, error) => reports.push(error) }, + ); + try { + await service.session(BOT_ID, { activity: true }); + await driven.emit([user(1, "Plan dinners")]); + failing = false; + await driven.emit([user(1, "Plan dinners"), user(2, "And lunches")]); + assert.deepEqual(userBubbles((await service.session(BOT_ID, { activity: true })).entries), [ + ["Plan dinners", null], + ["And lunches", "sub_2"], + ]); + assert.deepEqual(reports.map((error) => (error as Error).message), ["The session could not be inspected."]); + } 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 0e67fdcd5..71dbfe8fc 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -125,13 +125,6 @@ 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; -/** - * How many of the person's placed messages a projector remembers (entry → - * submission id) after their turns end; the newest are kept. Phones settle - * only their few unsent bubbles, and an older bubble without an id is matched - * by its text. - */ -export const AIDEN_REMOTE_BOT_SUBMISSION_MEMO_MAX = 64; export interface AidenRemoteBotSessionRuntime { send( @@ -185,7 +178,11 @@ export interface AidenRemoteBotSessionServiceOptions { * 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). Reported once per failing streak. */ + /** + * A queued input could not be classified (it stays off phones and is + * retried; reported once per failing streak), or the submission a new + * message was placed as could not be read (that bubble goes without its id). + */ onReport?(botId: string, error: unknown): void; /** Pause before a failed classification is retried; defaults to one second. */ classifyRetryMs?: number; @@ -542,9 +539,10 @@ class BotLiveProjector { private classifyRetry: ReturnType | undefined; /** * Entry id → submission id of the person's messages placed while this - * projector lived, newest last and at most `AIDEN_REMOTE_BOT_SUBMISSION_MEMO_MAX`: - * a finished turn's submission drops out of the session, but a reconnect - * snapshot still names it. + * 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. */ @@ -570,6 +568,7 @@ class BotLiveProjector { 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(); @@ -815,21 +814,30 @@ class BotLiveProjector { let placed: ReadonlyMap; try { placed = await this.activityDeps.placed(); - } catch { - // The field is optional: without it the phone falls back to the text. + } catch (error) { + // The field is optional: without it the phone matches this bubble by its text. + this.activityDeps.report(error); return this.submissionOf; } - for (const [entryId, submissionId] of [...placed].sort(([left], [right]) => Number(left) - Number(right))) { - if (!WIRE_SUBMISSION_ID.test(submissionId)) continue; - this.submissionOf.delete(entryId); - this.submissionOf.set(entryId, submissionId); - } - while (this.submissionOf.size > AIDEN_REMOTE_BOT_SUBMISSION_MEMO_MAX) { - this.submissionOf.delete(this.submissionOf.keys().next().value!); + for (const [entryId, submissionId] of placed) { + if (WIRE_SUBMISSION_ID.test(submissionId)) this.submissionOf.set(entryId, submissionId); } return this.submissionOf; } + /** 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); + } + } + /** One retry after the pause, on the serial lane, even if no frame arrives. */ private scheduleClassifyRetry(): void { const { retryMs } = this.activityDeps!; @@ -862,7 +870,11 @@ class BotLiveProjector { } 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. @@ -883,7 +895,7 @@ class BotLiveProjector { 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) { diff --git a/main/services/aiden-remote-protocol.test.ts b/main/services/aiden-remote-protocol.test.ts index 871b646d7..575011037 100644 --- a/main/services/aiden-remote-protocol.test.ts +++ b/main/services/aiden-remote-protocol.test.ts @@ -2490,10 +2490,12 @@ test("the person's own message carries its submissionId for bot:activity devices for (const sample of raw.botActivityPrivacy.rejectedSubmissionIdEntries) { assert.throws(() => parseAidenRemoteBotSessionEntry(sample), Error, JSON.stringify(sample)); } - assert.throws( - () => parseAidenRemoteBotSessionEntry(raw.botActivityPrivacy.rejectedSubmissionIdEntries[0]), - /Only the person's message can carry a submissionId/u, - ); + 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 () => { diff --git a/main/services/aiden-remote-protocol.ts b/main/services/aiden-remote-protocol.ts index 88d60b65a..54e749cf3 100644 --- a/main/services/aiden-remote-protocol.ts +++ b/main/services/aiden-remote-protocol.ts @@ -5023,7 +5023,8 @@ export function parseAidenRemoteBotSessionEntry(value: unknown): AidenRemoteBotS if (hasOwn(value, "interrupted") && (value.interrupted !== true || role !== "assistant")) { throw new Error("Only an assistant message can be marked interrupted."); } - if (hasOwn(value, "submissionId") && role !== "user") { + // 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") diff --git a/main/services/aiden-remote-service-main.ts b/main/services/aiden-remote-service-main.ts index c82d43368..deacada1e 100644 --- a/main/services/aiden-remote-service-main.ts +++ b/main/services/aiden-remote-service-main.ts @@ -884,7 +884,7 @@ async function createRuntime(): Promise { questions: botQuestions, approvals: botApprovals, turnIntents: botTurnIntents, - onReport: (botId, error) => logger.warn("aiden-remote", `Bot ${botId} queued messages could not be classified.`, error), + 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/protocol/aiden-remote/v1/fixtures/contract.json b/protocol/aiden-remote/v1/fixtures/contract.json index 7f95c995f..b0412a333 100644 --- a/protocol/aiden-remote/v1/fixtures/contract.json +++ b/protocol/aiden-remote/v1/fixtures/contract.json @@ -2219,6 +2219,7 @@ }, "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" }, diff --git a/protocol/aiden-remote/v1/openapi.json b/protocol/aiden-remote/v1/openapi.json index eb2b3b872..bf4d21fe3 100644 --- a/protocol/aiden-remote/v1/openapi.json +++ b/protocol/aiden-remote/v1/openapi.json @@ -14454,7 +14454,7 @@ "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 or a question answer, and omitted when the Mac no longer knows it; a device without bot:activity never receives the key.", + "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}$" } From 0c1e916f5f29f42649d955c84e6816fe9b471b8f Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 14:23:29 -0400 Subject: [PATCH 14/83] =?UTF-8?q?iOS=20Bots:=20Advanced=20=E2=86=92=20Chat?= =?UTF-8?q?=20activity=20with=20looping=20previews,=20saved=20on=20pick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Bots/AidenBotAdvancedView.swift | 367 ++++++++++++++++++ .../AidenBotSessionTests.swift | 127 ++++++ 2 files changed, 494 insertions(+) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift index 6a6da4e77..b47b63891 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift @@ -144,6 +144,7 @@ struct AidenBotAdvancedView: View { } modelSection(catalog) accessSection(catalog) + chatActivitySection if draft?.usesFullAccess == false { optionSection(title: "Connections", options: catalog.connections, keyPath: \.connectionIDs) optionSection(title: "Skills", options: catalog.skills, keyPath: \.skillIDs) @@ -212,6 +213,39 @@ struct AidenBotAdvancedView: View { } } + /// Revision 30: hidden unless the Mac announces `bot-activity-v1` and this phone holds `bot:activity`. + @ViewBuilder + private var chatActivitySection: some View { + if coordinator.server?.supportsBotActivity == true, let baselineBot { + Section { + NavigationLink { + AidenBotChatActivityView( + bot: baselineBot, + canWrite: coordinator.installationStore.activeInstallation?.canWriteBots == true, + 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 + // Advanced's own Save, and the screens behind it, move to the newest revision. + self.baselineBot = saved + onSaved(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.") + } + } + } + private func filesAndCommandsSection(_ catalog: AidenBotCapabilityCatalog) -> some View { Section { ForEach(aidenBotVisibleCapabilityOptionsForFiles(catalog)) { option in @@ -640,3 +674,336 @@ struct AidenBotModelListView: View { .disabled(!usable) } } + +// 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) + } + } +} + +/// 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, on the revision that save returned. +@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 + 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 onSaved: @MainActor (AidenBotDetail) -> Void + + init( + bot: AidenBotDetail, + save: @escaping @MainActor (AidenBotChatActivity, String) async throws -> AidenBotDetail, + onSaved: @escaping @MainActor (AidenBotDetail) -> Void + ) { + let current = bot.chatActivity ?? .standard + value = current + saved = current + revision = bot.revision + self.save = save + self.onSaved = onSaved + } + + func pick(_ next: AidenBotChatActivity) async { + guard next != value else { return } + value = next + errorMessage = nil + if isSaving { + pending = next + return + } + isSaving = true + defer { isSaving = false } + var target = next + while true { + do { + let detail = try await save(target, revision) + revision = detail.revision + saved = detail.chatActivity ?? target + onSaved(detail) + } catch { + pending = nil + value = saved + if !(error is CancellationError) { errorMessage = AidenBotChatActivityCopy.saveFailed } + return + } + guard let following = pending else { break } + pending = nil + if following == saved { break } + target = following + } + value = saved + } +} + +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) { + 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) + .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) + } + } + + /// 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( + ZStack { + palette.raised + if selected { palette.accent.opacity(0.12) } + } + ) + .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 + + private var still: Bool { systemReduceMotion || appReduceMotion } + + 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: still)) { context in + let index = still + ? AidenBotChatActivityPreview.stillFrame + : AidenBotChatActivityPreview.frameIndex(elapsed: context.date.timeIntervalSinceReferenceDate) + content(AidenBotChatActivityPreview.frame(index: index, level: level)) + } + .padding(10) + .background(palette.canvas, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .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 { + 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) + } +} diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index 5001e4c0f..dd4eeca3f 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -2582,3 +2582,130 @@ final class AidenBotConnectionHintTests: XCTestCase { } } } + +@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)) + } + + 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") + } + + 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) + } + + func testAPickShowsAtOnceAndASecondTapWhileSavingIsNeitherLostNorSentOnAStaleRevision() async throws { + var calls: [(AidenBotChatActivity, String)] = [] + var release: CheckedContinuation? + let model = AidenBotChatActivityModel(bot: try detail(revision: "rev_1", nil), save: { next, revision in + calls.append((next, revision)) + if calls.count == 1 { await withCheckedContinuation { release = $0 } } + return try self.detail(revision: "rev_\(calls.count + 1)", next) + }, onSaved: { _ in }) + + let first = Task { await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) } + while release == nil { await Task.yield() } + 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") + + release?.resume() + 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 testAFailedFollowUpReturnsToTheLastSavedChoice() async throws { + var count = 0 + let model = AidenBotChatActivityModel(bot: try detail(revision: "rev_1", nil), save: { next, _ in + count += 1 + if count == 2 { throw SaveFailure() } + return try self.detail(revision: "rev_2", next) + }, onSaved: { _ in }) + 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 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, []) + XCTAssertEqual(AidenBotChatActivityPreview.frame(index: searching, level: .summary).steps, []) + XCTAssertEqual(AidenBotChatActivityPreview.frame(index: searching, level: .detailed).steps.count, searching, "Detailed lists each finished step while it works") + + 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") + XCTAssertNotNil(AidenBotChatActivityPreview.frame(index: AidenBotChatActivityPreview.stillFrame, level: .quiet).phase, + "Reduce Motion's still frame shows the line mid-turn") + } + + func testTheAdvancedRowSummarizesTheChoice() { + XCTAssertEqual(AidenBotChatActivityCopy.summary(nil), "Quiet · Follows the work") + XCTAssertEqual(AidenBotChatActivityCopy.summary(AidenBotChatActivity(level: .detailed, wording: .thinking)), "Detailed · Always “Thinking”") + } +} From d3b9444ea25f3836751ac2bf4a6c68bfe4ef4bd7 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 14:38:19 -0400 Subject: [PATCH 15/83] Android Bots: revision 30 activity wire, bot:activity opt-in, shared status words Co-Authored-By: Claude Sonnet 5.5 --- .../features/bots/AidenBotActivity.kt | 105 +++++ .../bots/AidenBotSessionController.kt | 5 + .../features/remote/AidenRemoteCoordinator.kt | 6 + .../sbtbiswas/AidenOnTheGo/models/AidenBot.kt | 38 +- .../AidenOnTheGo/models/AidenBotSession.kt | 116 ++++- .../AidenOnTheGo/models/AidenInstallation.kt | 8 + .../networking/AidenRemoteClient.kt | 9 +- .../protocol/AidenRemoteProtocol.kt | 14 +- android/app/src/main/res/values/strings.xml | 24 ++ .../AidenBotActivityContractTest.kt | 406 ++++++++++++++++++ .../AidenBotRevision25ClientTest.kt | 32 ++ 11 files changed, 751 insertions(+), 12 deletions(-) create mode 100644 android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt create mode 100644 android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt 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..e4c94facc --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt @@ -0,0 +1,105 @@ +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" +} + +/** 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) +} 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..e4c40fba7 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 @@ -164,6 +164,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) } 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..f097841c4 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 @@ -266,6 +266,12 @@ class AidenRemoteCoordinator( if (!installation.hasNegotiatedAccess(capability)) add(capability) } } + // 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) + } + } } if (requested.isEmpty()) return server return try { 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..4b7091fb2 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,10 +414,12 @@ 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) } 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..09cf9cfd7 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBotSession.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBotSession.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.KSerializer 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 @@ -174,7 +175,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 +190,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") + } + } } } @@ -434,6 +446,56 @@ 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. + */ +@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 (!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 +511,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 +526,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 +541,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 +554,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( @@ -520,7 +589,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 +609,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 +689,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 +731,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 +762,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 +834,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..fec6f788c 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,14 @@ data class AidenServer( val offersBotCards: Boolean get() = supportsBotMemory || supportsBotProactive + /** 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) + /** * Chat messages may carry inline `visuals` (revision 28). Informational: * the transcript renders whatever visuals a message carries. 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..d8680e452 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt @@ -1432,9 +1432,14 @@ 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 { + suspend fun sendBotMessage( + botId: String, + text: String, + idempotencyKey: UUID, + whenBusy: AidenStreamInputMode? = null + ): AidenBotSessionSendResponse { AidenBotWire.validateIdentifier(botId, "botId", AidenRemoteProtocol.MAX_BOT_IDENTIFIER_LENGTH) - val body = botJson.encodeToString(AidenBotSessionSendRequest.serializer(), AidenBotSessionSendRequest(text)) + val body = botJson.encodeToString(AidenBotSessionSendRequest.serializer(), AidenBotSessionSendRequest(text, whenBusy)) return executeRequest( "/bots/$botId/messages", method = "POST", 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/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 638b95867..ea185dbc5 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -619,6 +619,30 @@ 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 + Queued + Sends when the current reply finishes Open %1$s’s profile Aiden couldn’t open this Bot’s chat. Try again. Connect to your Mac to open this chat. 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..16d51cab7 --- /dev/null +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt @@ -0,0 +1,406 @@ +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.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)) + // 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 + ) + } + + // 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. + private fun macWith(features: List, capabilities: List) = AidenServer( + instanceId = "inst_activity", + name = "Mac", + capabilities = capabilities, + features = features + ) + + @Test + fun botActivitySupportNeedsTheFeatureAndTheGrant() { + val feature = AidenRemoteProtocol.BOT_ACTIVITY_FEATURE + val grant = AidenRemoteCapability.BOT_ACTIVITY + val base = listOf(AidenRemoteCapability.CHAT_READ) + // offers / supports: (feature, grant) -> (offers, supports) + val none = macWith(emptyList(), base) + assertFalse(none.offersBotActivity) + assertFalse(none.supportsBotActivity) + val featureOnly = macWith(listOf(feature), base) + assertTrue(featureOnly.offersBotActivity) + assertFalse(featureOnly.supportsBotActivity) + // A grant without the feature is not an offer: an older Mac is never asked, and never trusted. + val grantOnly = macWith(emptyList(), base + grant) + assertFalse(grantOnly.offersBotActivity) + 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) + // The grant is negotiable like the other phone grants. + assertTrue(AidenRemoteCapability.NEGOTIABLE.contains(grant)) + assertTrue(AidenRemoteCapability.PHONE_KNOWN.contains(grant)) + assertEquals(listOf(grant), AidenRemoteCapability.PHONE_BOT_ACTIVITY) + } + + 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)) + } +} diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt index 7205aed61..1bc041a4c 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt @@ -340,4 +340,36 @@ 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) + 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())) + + // Without a mode the body is the revision-29 body, byte for byte. + server.enqueue(ok(activity.jsonObject.getValue("response"), 202)) + client.sendBotMessage("bot_fixture_01", "Hi", UUID.randomUUID()) + assertEquals( + Json.parseToJsonElement("""{"text":"Hi"}"""), + Json.parseToJsonElement(server.takeRequest().body.readUtf8()) + ) + } } From ee4c1ad65985648d4efc06a1da59016fe4e0f6c6 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 14:56:36 -0400 Subject: [PATCH 16/83] Android Bots: status line state, Reconnecting, queued messages and Steer/Queue in the session controller Optimistic sends through a durable outbox (R14, R16, R17): exact reconcile by submissionId with text only as a fallback, per-epoch claims, head-of-line ordering, bounded backoff, failed (never dropped) on exhaustion or 24h TTL, noBackup disk persistence keyed by pairing with owner-guarded writes and per-record decode, purged on unpair, 401/403 and accessChanged. Stop, Resume, Retry, Dismiss, answers, approvals and proposals apply at once and roll back only what nothing newer replaced (R18). Status line per R7. Co-Authored-By: Claude Opus 5.5 --- .../AidenOnTheGo/AidenAppContainer.kt | 3 +- .../features/bots/AidenBotOutbox.kt | 348 +++++++ .../bots/AidenBotSessionController.kt | 974 ++++++++++++++---- .../features/bots/AidenBotSessionScreen.kt | 11 +- .../features/remote/AidenRemoteCoordinator.kt | 8 +- .../bots/AidenBotSessionControllerTest.kt | 753 +++++++++++++- 6 files changed, 1893 insertions(+), 204 deletions(-) create mode 100644 android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt 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/AidenBotOutbox.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt new file mode 100644 index 000000000..df6e94bfe --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt @@ -0,0 +1,348 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.encodeToJsonElement +import sbtbiswas.AidenOnTheGo.models.AidenBotSessionEntry +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 + +/** + * 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 +) { + /** 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) + 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 + ) + } +} + +/** 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 = {} +) { + private val transcripts = LinkedHashMap>(CAPACITY, 0.75f, true) + private val outboxes = HashMap>() + private val read = HashSet() + private val owners = HashMap() + + @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()) + } + + /** The chat's unsent messages, oldest first, now written by [owner] only. */ + @Synchronized + fun adoptOutbox(key: AidenBotSessionKey, owner: Any): List { + owners[key] = owner + if (read.add(key)) { + val stored = load(key) + if (stored.isNotEmpty()) outboxes[key] = stored + } + return outboxes[key].orEmpty() + } + + /** Keeps [messages] when [owner] writes this chat; false when another controller does, or after an unpair. */ + @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 + val records = messages.map(Record::of) + io.execute { write(key, records) } + return true + } + + /** 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 + io.execute { fileFor(key)?.delete() } + } + + /** The Mac was unpaired: every chat of it goes, and no controller still open may write again. */ + @Synchronized + fun purgeInstance(instanceId: String) { + transcripts.keys.removeAll { it.instanceId == instanceId } + outboxes.keys.removeAll { it.instanceId == instanceId } + owners.keys.removeAll { it.instanceId == instanceId } + read.removeAll { it.instanceId == instanceId } + val dir = root?.let { File(it, digest(instanceId)) } + io.execute { dir?.deleteRecursively() } + } + + // --- 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 + ) + + /** + * One chat's messages from disk. Each record decodes on its own, so a bad one costs only + * itself. One still unsent after 24 hours comes back as not sent; a taken one that old is + * the Mac's now. + */ + private fun load(key: AidenBotSessionKey): List { + val file = fileFor(key) ?: return emptyList() + if (!file.exists()) return emptyList() + val envelope = try { + if (file.length() > MAX_BYTES) throw IllegalStateException("too large") + json.decodeFromString(Envelope.serializer(), file.readText(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=dropped") + file.delete() + return emptyList() + } + val readable = envelope.messages.mapNotNull { element -> + try { + json.decodeFromJsonElement(Record.serializer(), element).message() + } catch (_: Exception) { + null + } + } + if (readable.size != envelope.messages.size) { + report("event=bot_outbox_record_unreadable outcome=dropped count=${envelope.messages.size - readable.size}") + } + 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 + } + }.take(MAX_MESSAGES) + if (messages != readable) { + val records = messages.map(Record::of) + io.execute { write(key, records) } + } + return messages + } + + private fun write(key: AidenBotSessionKey, records: List) { + val file = fileFor(key) ?: return + try { + if (records.isEmpty()) { + file.delete() + return + } + val envelope = Envelope(1, key.instanceId, key.deviceId, key.botId, records.map { json.encodeToJsonElement(it) }) + file.parentFile?.mkdirs() + val temp = File(file.parentFile, "${file.name}.tmp") + temp.writeText(json.encodeToString(Envelope.serializer(), envelope), Charsets.UTF_8) + if (!temp.renameTo(file)) { + temp.delete() + report("event=bot_outbox_write outcome=failed") + } + } catch (_: Exception) { + report("event=bot_outbox_write outcome=failed") + } + } + + private 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 + /** The most messages one chat may hold waiting; another is refused, visibly. */ + const val MAX_MESSAGES = 20 + const val CAPACITY = 16 + private const val MAX_BYTES = 4_000_000L + 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 } } + } + + 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/AidenBotSessionController.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionController.kt index e4c40fba7..2232ac930 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 @@ -21,7 +21,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 @@ -46,7 +47,8 @@ interface AidenBotSessionTransport { 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) 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) @@ -206,16 +208,42 @@ 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 } + +/** 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, + /** 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, @@ -223,20 +251,78 @@ data class AidenBotSessionUiState( /** 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 @@ -244,49 +330,152 @@ 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, + 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 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 now = nowMs() + val cached = sessionKey?.let { store?.transcript(it) }.orEmpty() + val stored = sessionKey?.let { store?.adoptOutbox(it, this) }.orEmpty() + val adopted = stored.map { it.adopted(now) } + _state = MutableStateFlow( + AidenBotSessionUiState( + knownState = knownState, + activitySupported = activitySupported, + cachedEntries = cached, + outbox = adopted + ) + ) + state = _state.asStateFlow() + if (adopted != stored && sessionKey != null) store?.storeOutbox(sessionKey, this, adopted) + } + + fun setActivitySupported(supported: Boolean) { + _state.update { it.copy(activitySupported = supported) } + } + + fun setWording(wording: AidenBotChatActivityWording) { + _state.update { it.copy(wording = wording) } + } /** Starts loading and following the session; safe to call once per screen. */ fun start() { + released = false if (feedJob?.isActive == true) return feedJob = scope.launch { follow() } } + /** The chat closed: the feed and retry timers stop, and unsent messages stay for the next visit. */ fun stopFollowing() { + released = true feedJob?.cancel() feedJob = null + flushJob?.cancel() + backoffJob?.cancel() + downJob?.cancel() } - /** Reloads `GET /bots/{id}/session`; returns false when it could not. */ - suspend fun refetch(): Boolean { + /** Reloads `GET /bots/{id}/session`; returns false when it could not. One older than frames already shown is dropped. */ + suspend fun refetch(): Boolean = load(authoritative = false) + + private suspend fun load(authoritative: Boolean): Boolean { return try { val session = transport.session(botId) - _state.update { it.copy(session = session, isLoading = false, loadFailed = false) } + 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 + if (aidenBotLostAccess(error)) purgeForAccessLoss() _state.update { it.copy(isLoading = false, loadFailed = it.session == null, botMissing = it.botMissing || missing) } + if (missing) failWaiting() false } } @@ -295,147 +484,589 @@ 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) } + 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. + */ + 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 (current.resumeIntent == AidenBotTurnIntent.ACKNOWLEDGED) 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) { + // 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) } 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) } + val mode = if (current.offersBusyModes) whenBusy ?: AidenStreamInputMode.QUEUE else null + if (!enqueue(trimmed, mode, retryOf = null)) return false + flushOutbox() + return true + } + + /** Adds a message, unless the chat already holds as many as it can keep: then it says so. */ + private fun enqueue(text: String, mode: AidenStreamInputMode?, retryOf: String?): Boolean { + val current = _state.value + if (current.outbox.size >= AidenBotSessionStore.MAX_MESSAGES) { + _state.update { it.copy(actionError = OUTBOX_FULL) } + return false + } + setOutbox(current.outbox + AidenBotOutgoingMessage.create(text, mode, current.isRunning, retryOf, nowMs())) + return true + } + + /** 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 + 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 + ) + setOutbox(_state.value.outbox.map { if (it.id == id) again else it }) + flushOutbox() + 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 + flushOutbox() + return true + } + + private fun setOutbox(next: List) { + if (_state.value.outbox == next) return + _state.update { it.copy(outbox = next) } + val key = sessionKey ?: return + store?.storeOutbox(key, this, next) + } + + /** 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 }) + } + + private fun cursor(): String = _state.value.session.let { "${it?.epoch}\u001f${it?.seq}" } + + /** + * 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 snapshot + * also drops a taken message that is neither queued nor in the transcript. + */ + 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) + } else if (snapshot && current.activitySupported) { + // Taken, not queued and not in the transcript: nothing on the Mac holds it. + continue + } + } 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) + } + + /** 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. + val theirs = sent.submissionId + if (theirs != null && (message.submissionId != null || theirs in owned)) 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 beside whatever called this (a frame, a snapshot, a backoff), never holding up the feed. */ + private fun scheduleFlush() { + if (released || isFlushing || nextToDeliver() == null) return + flushJob = scope.launch { flushOutbox() } + } + + /** The oldest unsettled message, unless it waits for a retry: nothing overtakes it, so messages arrive in order. */ + private fun nextToDeliver(): UUID? { + val current = _state.value + if (!current.isConnected || released) 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 - if (trimmed.isEmpty() || !current.canSend) return false - if (pendingSendText != trimmed) { - keys.complete(SEND) - pendingSendText = trimmed + val message = current.outbox.firstOrNull { it.key == key } ?: return + if (current.needsModel || current.state == AidenBotSessionState.UNAVAILABLE || current.botMissing) { + updateMessage(key) { it.copy(status = AidenBotOutgoingStatus.FAILED, echoed = false) } + return + } + if (message.isExpired(nowMs())) { + // Unsent for a day: it says so instead of going out late. + updateMessage(key) { it.expired() } + return } - val key = keys.key(SEND) - _state.update { it.copy(isSending = true, actionError = null) } + 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 } + val mark = cursor() + _state.update { it.copy(isSending = true) } + try { + val receipt = transport.send(botId, request.text, key, request.sentMode) + if (cursor() == mark) 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 { + // The Mac's transcript may already show it; otherwise "Couldn't send · Try again", key kept. + updateMessage(key) { + if (it.echoed) null else it.copy(status = AidenBotOutgoingStatus.FAILED, keepsKey = true, connectionAttempts = attempts) + } + } + } 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 } + backoffJob?.cancel() + setOutbox(emptyList()) + sessionKey?.let { store?.purge(it) } + if (hadUnsent) _state.update { it.copy(actionError = 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 + 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 mark = cursor() return try { - val receipt = transport.send(botId, trimmed, key) - keys.complete(SEND) - pendingSendText = null - applyStateView(receipt.stateView) + val view = transport.resume(botId, keys.key(RESUME)) + keys.complete(RESUME) + if (cursor() == mark) 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 + ) + } + val mark = cursor() + return try { + val view = transport.dismiss(botId, keys.key(DISMISS)) + keys.complete(DISMISS) + if (cursor() == mark) 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 mark = cursor() return try { - val receipt = transport.send(botId, text, keys.key(action)) - keys.complete(action) - applyStateView(receipt.stateView) + val view = transport.stop(botId, keys.key(STOP)) + keys.complete(STOP) + if (cursor() == mark) 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 @@ -445,24 +1076,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)) 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) } @@ -470,19 +1104,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 @@ -495,7 +1131,6 @@ class AidenBotSessionController( transport.respondToApproval(approval.waitId, decision, keys.key(action)) keys.complete(action) pendingApprovalDecision = null - clearApproval(approval.waitId) true } catch (error: CancellationException) { throw error @@ -503,13 +1138,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 { @@ -517,13 +1155,6 @@ 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 @@ -543,10 +1174,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 @@ -562,6 +1193,15 @@ 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)) @@ -572,6 +1212,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) { @@ -582,73 +1223,54 @@ 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." + const val ACCESS_LOST = "That message wasn’t sent. Please try again." + const val OUTBOX_FULL = "Too many messages are waiting to send. Try again once they’ve gone." } } 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..ca40e0f7b 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 @@ -135,7 +135,16 @@ 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, + sessionKey = sessionKey, + store = coordinator.botSessionStore + ) } DisposableEffect(controller) { controller.start() 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 f097841c4..3b3079dcb 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 @@ -46,13 +47,17 @@ 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 ) { 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 = AidenBotSessionStore(File(botOutboxDir, "BotOutbox-v1")) val modelPreferenceStore = AidenModelPreferenceStore(storageDir) val readSnapshotCache = AidenReadSnapshotCache(File(storageDir, "read_snapshots")) private val workspaceListSerializer = ListSerializer(AidenWorkspace.serializer()) @@ -356,6 +361,7 @@ class AidenRemoteCoordinator( 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/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt index 8041d6e52..72ec0a98f 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,6 +5,7 @@ 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 @@ -18,8 +19,10 @@ 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.Test +import sbtbiswas.AidenOnTheGo.features.shared.activitymarks.AidenActivityMarkKind import sbtbiswas.AidenOnTheGo.models.* import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteClientException import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteErrorCode @@ -53,15 +56,24 @@ class AidenBotSessionControllerTest { 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 +86,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 +116,7 @@ class AidenBotSessionControllerTest { key: UUID ): AidenBotQuestionAnswerReceipt { answerKeys += key + answerGate?.await() if (failNextAnswer) { failNextAnswer = false throw IOException("offline") @@ -113,6 +143,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 +152,7 @@ class AidenBotSessionControllerTest { key: UUID ): AidenBotRoutineProposalRespondResult { proposalCalls += ProposalCall(proposalId, decision, key) + proposalGate?.await() proposalFailures.removeFirstOrNull()?.let { throw it } return when (decision) { AidenBotRoutineProposalDecision.ACCEPT -> @@ -452,7 +484,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 +511,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 +528,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 +798,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()) - assertEquals(5, transport.sends.size) - assertTrue(transport.sends.take(4).none { it.second == transport.sends[4].second }) + val failed = controller.state.value.outbox.single { it.retryOf == "entry_20:failed" } + assertEquals(AidenBotOutgoingLook.Failed, controller.state.value.look(failed)) + assertTrue(controller.retry()) + 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 +904,666 @@ 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) = + AidenBotSessionStore(root, io = { it.run() }, nowMs = clock) + + private val chatKey = AidenBotSessionKey("instance_fixture_01", "device_fixture_01", "bot_fixture_01") + + @Test + fun anOutboxOutlivesLeavingTheChatAndProcessDeathAndGoesOutUnderItsKey() = runTest { + val root = kotlin.io.path.createTempDirectory("bot-outbox").toFile() + 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")) + assertTrue(leaving.send("And dinner")) + leaving.stopFollowing() + + // Back to the chat in the same run: the same messages, still unsent. + val back = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = first, nowMs = { clock } + ) + assertEquals(listOf("Plan lunch", "And dinner"), 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 them from disk. + clock += 60_000 + val relaunched = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = store(root, { clock }), nowMs = { clock } + ) + val restored = relaunched.state.value.outgoing + assertEquals(listOf("Plan lunch", "And dinner"), restored.map { it.text }) + assertEquals(back.state.value.outbox.map { it.key }, restored.map { it.key }) + relaunched.refetch() + runCurrent() + assertEquals(listOf("Plan lunch", "And dinner"), transport.sends.map { it.first }) + assertEquals(restored.map { it.key }, transport.sends.map { it.second }) + root.deleteRecursively() + } + + @Test + fun aMessageUnsentForADayComesBackAsCouldntSendAndABadRecordCostsOnlyItself() = runTest { + val root = kotlin.io.path.createTempDirectory("bot-outbox").toFile() + 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")) + 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 } + ) + val restored = later.state.value.outgoing + assertEquals(listOf("Plan lunch"), restored.map { it.text }) + assertEquals(AidenBotOutgoingLook.Failed, later.state.value.look(restored.single())) + later.refetch() + runCurrent() + 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()) + root.deleteRecursively() + } + + @Test + fun losingAccessOnTheStreamPurgesTheOutboxEverywhereAndSaysSo() = runTest { + val root = kotlin.io.path.createTempDirectory("bot-outbox").toFile() + val sessions = store(root, { started }) + val transport = FakeTransport(runningSession) + transport.feeds += flow { throw serverError(401, AidenRemoteErrorCode.AUTHENTICATION_REQUIRED) } + val controller = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions, nowMs = { started } + ) + assertTrue(controller.send("Plan lunch")) + assertTrue(root.walkTopDown().any { it.isFile }) + controller.start() + runCurrent() + assertTrue(controller.state.value.outbox.isEmpty()) + assertEquals("That message wasn’t sent. Please try again.", controller.state.value.actionError) + assertTrue("nothing unsent stays on disk", root.walkTopDown().none { it.isFile }) + assertNull(sessions.transcript(chatKey)) + root.deleteRecursively() + } + + @Test + fun aBotWhoseAccessChangedAndAnUnpairedMacKeepNothingUnsent() = runTest { + val root = kotlin.io.path.createTempDirectory("bot-outbox").toFile() + val sessions = store(root, { started }) + val transport = FakeTransport(runningSession) + val controller = AidenBotSessionController( + runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions, nowMs = { started } + ) + controller.refetch() + 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("That message wasn’t sent. Please try again.", controller.state.value.actionError) + + // 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")) + sessions.purgeInstance(chatKey.instanceId) + assertTrue(root.walkTopDown().none { it.isFile }) + assertTrue(other.send("Still there?")) + assertTrue(root.walkTopDown().none { it.isFile }) + root.deleteRecursively() + } + + @Test + fun aChatOpensOnItsCachedTranscriptAndShowsSkeletonsOnlyWithNothingToShow() = runTest { + val sessions = AidenBotSessionStore(null) + 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) + } + + @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]) + } } From 0d1ff3b996b3cb19a97aa19d486b0daafa81be88 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 14:59:40 -0400 Subject: [PATCH 17/83] iOS Bots: rollback by field, no silent drops, owned outbox writes Failed Resume, Dismiss, answers and approvals roll back whatever the Mac hasn't written since the tap, even across unrelated frames. A message unsent after 24 hours, or out of tries, says Couldn't send instead of vanishing, on restore and in memory. A stream 401/403 purges with the notice and a failed refresh keeps it. Outbox writes are owner-guarded on disk as in the cache, coalesced, ordered before a purge, and stop on release; load() sends structurally. The outbox file is keyed by the pairing's deviceId, decodes record by record, refuses a 21st message visibly and is excluded from backup. An echo settles only on a receipt with its submissionId; 409 idempotency_in_flight keeps the message key; Stop clears on a new run's first activity; Retry waits for a load. Co-Authored-By: Claude Opus 5.5 --- .../Bots/AidenBotSessionChatView.swift | 4 +- .../Features/Bots/AidenBotSessionModel.swift | 313 +++++++++++++----- .../AidenBotSessionTests.swift | 302 ++++++++++++++++- 3 files changed, 522 insertions(+), 97 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift index 5b00a8ba2..ccafb9ebe 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift @@ -365,7 +365,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() } } ) @@ -654,7 +654,7 @@ struct AidenBotSessionChatView: View { let model = AidenBotSessionModel( botID: botID, transport: client, - cacheKey: AidenBotSessionKey(instanceId: context.instanceId, botID: botID) + cacheKey: AidenBotSessionKey(instanceId: context.instanceId, deviceId: context.deviceId, botID: botID) ) model.activitySupported = coordinator.server?.supportsBotActivity == true self.model = model diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index 24516b2d7..ef6c5a2b2 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -1,6 +1,7 @@ import CryptoKit import Foundation import Observation +import os /// Host feature tokens added in contract revision 25. enum AidenBotHostFeature { @@ -226,8 +227,11 @@ struct AidenBotStatusAnnouncer { } /// 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 } @@ -267,6 +271,9 @@ struct AidenBotOutgoingMessage: Equatable, Identifiable, Sendable { /// 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? /// Waits for the next connection or backoff before trying again. fileprivate var deferred = false fileprivate var connectionAttempts = 0 @@ -278,6 +285,14 @@ struct AidenBotOutgoingMessage: Equatable, Identifiable, Sendable { 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 + echoed = false + } + /// Whether the Mac may hold it, so its echo can stand in for it. fileprivate var mayHaveReachedMac: Bool { guard request != nil else { return false } @@ -328,10 +343,13 @@ final class AidenBotSessionCache { return outboxes[key] ?? [] } - func storeOutbox(_ outbox: [AidenBotOutgoingMessage], for key: AidenBotSessionKey, owner: ObjectIdentifier) { - guard owners[key] == nil || owners[key] == owner else { return } + /// 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). @@ -349,12 +367,16 @@ final class AidenBotSessionCache { } /// The unsent messages of each Bot chat on disk, so they survive a relaunch -/// (R17): protected until first unlock, kept 24 hours, purged with the pairing. +/// (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 - static let maxMessages = 50 + /// The most messages one chat may hold waiting; another is refused, visibly. + static let maxMessages = 20 private static let maxBytes = 4_000_000 + private static let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "sbtbiswas.AidenOnTheGo", category: "BotOutbox") private struct Record: Codable { let key: UUID @@ -371,13 +393,28 @@ actor AidenBotOutboxStore { let keepsKey: Bool } - private struct Envelope: Codable { + /// 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 @@ -396,21 +433,32 @@ actor AidenBotOutboxStore { } } - /// The chat's unsent messages younger than 24 hours, oldest first. + /// The chat's messages, oldest first. One still unsent after 24 hours comes + /// back as not sent; a taken one that old is the Mac's now. func load(_ key: AidenBotSessionKey) -> [AidenBotOutgoingMessage] { let url = fileURL(key) - guard let data = try? Data(contentsOf: url), - data.count <= Self.maxBytes, - let envelope = try? JSONDecoder().decode(Envelope.self, from: data), - envelope.version == 1, envelope.instanceId == key.instanceId, envelope.botID == key.botID else { + guard let data = try? Data(contentsOf: url) else { return [] } + guard data.count <= Self.maxBytes, + 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 { + Self.log.error("event=outbox_unreadable outcome=dropped") + try? fileManager.removeItem(at: url) return [] } - let cutoff = now().addingTimeInterval(-Self.lifetime) - let fresh = envelope.messages.filter { $0.createdAt > cutoff && $0.createdAt <= now().addingTimeInterval(60) } - if fresh.count != envelope.messages.count { - if fresh.isEmpty { try? fileManager.removeItem(at: url) } else { try? write(fresh, for: key) } + let readable = envelope.messages.compactMap(\.record) + if readable.count != envelope.messages.count { + Self.log.error("event=outbox_record_unreadable outcome=dropped count=\(envelope.messages.count - readable.count, privacy: .public)") } - return fresh.map { record in + 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, @@ -424,12 +472,18 @@ actor AidenBotOutboxStore { message.baseline = Set(record.baseline) message.connectionAttempts = record.connectionAttempts message.keepsKey = record.keepsKey - return message + if expired, message.status == .sending { + message.expire() + changed = true + } + messages.append(message) } + if changed { save(messages, for: key) } + return messages } func save(_ messages: [AidenBotOutgoingMessage], for key: AidenBotSessionKey) { - let records = messages.suffix(Self.maxMessages).map { message in + let records = messages.map { message in Record( key: message.key, text: message.text, @@ -447,8 +501,12 @@ actor AidenBotOutboxStore { } if records.isEmpty { remove(key) - } else { - try? write(Array(records), for: key) + return + } + do { + try write(records, for: key) + } catch { + Self.log.error("event=outbox_write outcome=failed") } } @@ -461,19 +519,24 @@ actor AidenBotOutboxStore { } private func write(_ records: [Record], for key: AidenBotSessionKey) throws { - let data = try JSONEncoder().encode(Envelope(version: 1, instanceId: key.instanceId, botID: key.botID, messages: records)) - guard data.count <= Self.maxBytes else { return } + let data = try JSONEncoder().encode(Envelope( + version: 1, instanceId: key.instanceId, deviceId: key.deviceId, botID: key.botID, messages: records + )) let url = fileURL(key) - try fileManager.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true, - attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] - ) + let protection: [FileAttributeKey: Any] = [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] + try fileManager.createDirectory(at: root, withIntermediateDirectories: true, attributes: protection) + // Unsent messages stay on this device: never in a backup. + 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 { root.appending(path: digest(key.instanceId), directoryHint: .isDirectory) + .appending(path: digest(key.deviceId), directoryHint: .isDirectory) .appending(path: "\(digest(key.botID)).json") } @@ -578,12 +641,22 @@ final class AidenBotSessionModel { /// The run a Stop was pressed in, by its `startedAt`. @ObservationIgnored private var stopStartedAt: Int? @ObservationIgnored private var flushTask: Task? + @ObservationIgnored private var isFlushing = false @ObservationIgnored private var backoffTask: Task? @ObservationIgnored private var restoreTask: Task? @ObservationIgnored private var persistTask: Task? + /// The newest disk write not yet made; only the latest one matters. + @ObservationIgnored private var pendingPersist: PersistOp? @ObservationIgnored private var released = false + @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 } + private enum PersistOp { case save([AidenBotOutgoingMessage]), remove } /// Tries a message gets on a connection that keeps failing before it says "Couldn't send". static let maxConnectionAttempts = 3 @@ -599,7 +672,8 @@ final class AidenBotSessionModel { cacheKey: AidenBotSessionKey? = nil, cache: AidenBotSessionCache = .shared, outboxStore: AidenBotOutboxStore? = .shared, - retryDelays: [Duration] = [.seconds(1), .seconds(4)] + retryDelays: [Duration] = [.seconds(1), .seconds(4)], + now: @escaping () -> Date = { Date() } ) { self.botID = botID self.transport = transport @@ -607,22 +681,34 @@ final class AidenBotSessionModel { self.cache = cache self.outboxStore = cacheKey == nil ? nil : outboxStore self.retryDelays = retryDelays + self.now = now if let cacheKey { entries = cache.transcript(for: cacheKey) ?? [] - outbox = cache.adoptOutbox(for: cacheKey, owner: ObjectIdentifier(self)).map(Self.adopted) + 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. - private static func adopted(_ message: AidenBotOutgoingMessage) -> AidenBotOutgoingMessage { + /// 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.echoed = false + message.echoEntryID = nil + message.echoSubmissionID = nil message.queuedID = nil + if message.status == .sending, isExpired(message, now: now) { message.expire() } return message } - /// The chat closed: timers stop, and the unsent messages stay for the next visit. + 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. A disk write + /// already queued finishes, so the next launch sees the latest outbox. func release() { released = true backoffTask?.cancel() @@ -717,7 +803,7 @@ final class AidenBotSessionModel { func load() async { await restoreOutbox() _ = await fetchSnapshot() - await flushTask?.value + await flushOutbox() } /// `GET /session`. Returns false when no snapshot was loaded. A snapshot @@ -732,7 +818,11 @@ final class AidenBotSessionModel { } catch is CancellationError { return false } catch { - if Self.isAccessLoss(error) { purgeForAccessLoss() } + // 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 } @@ -755,6 +845,7 @@ final class AidenBotSessionModel { 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 @@ -819,13 +910,16 @@ final class AidenBotSessionModel { 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`) has nothing to stop. - if stopIntent == .acknowledged || (next != nil && stopStartedAt != nil && next?.startedAt != stopStartedAt) { + // 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 } if resumeIntent == .acknowledged { resumeIntent = .none } @@ -860,6 +954,8 @@ final class AidenBotSessionModel { setStateView(session.stateView) question = session.question approval = session.approval + questionWrites += 1 + approvalWrites += 1 activity = session.activity queued = session.queued hasLoaded = true @@ -875,6 +971,7 @@ final class AidenBotSessionModel { private func setStateView(_ view: AidenBotSessionStateView?) { let wasBlocked = stateView?.blocked == .accessChanged stateView = view + stateWrites += 1 if view?.state != .running { stopIntent = .none resumeIntent = .none @@ -907,9 +1004,11 @@ final class AidenBotSessionModel { continue } if !message.echoed, let echo = echoEntry(for: message, owned: owned) { - claimedEntries.insert(echo) + 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 { @@ -932,34 +1031,55 @@ final class AidenBotSessionModel { } /// The unclaimed user entry that stands for `message`, if any. - private func echoEntry(for message: AidenBotOutgoingMessage, owned: Set) -> String? { - var byText: String? + 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.id } + 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. if message.submissionId != nil || owned.contains(theirs) { continue } } - byText = sent.id + 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. private func outboxDidChange() { - guard let cacheKey, !released else { return } - cache.storeOutbox(outbox, for: cacheKey, owner: ObjectIdentifier(self)) - guard let outboxStore else { return } - let snapshot = outbox - let previous = persistTask - persistTask = Task { - await previous?.value - await outboxStore.save(snapshot, for: cacheKey) + guard let cacheKey, !released, + cache.storeOutbox(outbox, for: cacheKey, owner: ObjectIdentifier(self)) else { return } + persist(.save(outbox)) + } + + /// One writer per model; a newer write replaces one not yet made, and a + /// purge runs after any write already under way. + private func persist(_ op: PersistOp) { + guard let cacheKey, let outboxStore else { return } + pendingPersist = op + guard persistTask == nil else { return } + persistTask = Task { [weak self] in + while let next = self?.takePersistOp() { + switch next { + case let .save(messages): await outboxStore.save(messages, for: cacheKey) + case .remove: await outboxStore.remove(cacheKey) + } + } + } + } + + private func takePersistOp() -> PersistOp? { + guard let op = pendingPersist else { + persistTask = nil + return nil } + pendingPersist = nil + return op } /// Adds what a previous launch left on disk, once, before the first snapshot sends it. @@ -970,7 +1090,8 @@ final class AidenBotSessionModel { let stored = await outboxStore.load(cacheKey) guard let self, !stored.isEmpty else { return } let known = Set(self.outbox.map(\.key)) - let added = stored.filter { !known.contains($0.key) }.map(Self.adopted) + let now = self.now() + let added = stored.filter { !known.contains($0.key) }.map { Self.adopted($0, now: now) } if !added.isEmpty { self.outbox = (added + self.outbox).sorted { $0.createdAt < $1.createdAt } } @@ -985,8 +1106,9 @@ final class AidenBotSessionModel { scheduleFlush() } + /// Sends the outbox beside whatever called this (a frame, a snapshot, a backoff). private func scheduleFlush() { - guard !released, flushTask == nil, nextToDeliver() != nil else { return } + guard !released, !isFlushing, nextToDeliver() != nil else { return } flushTask = Task { [weak self] in await self?.flushOutbox() } } @@ -999,11 +1121,15 @@ final class AidenBotSessionModel { return head.key } + /// One sender at a time: a caller that finds one under way leaves the new + /// message to it. private func flushOutbox() async { + guard !isFlushing else { return } + isFlushing = true + defer { isFlushing = false } while let key = nextToDeliver(), !Task.isCancelled { await deliver(key) } - flushTask = nil } private func deliver(_ key: UUID) async { @@ -1013,6 +1139,11 @@ final class AidenBotSessionModel { outbox[index].echoed = false return } + if Self.isExpired(outbox[index], now: now()) { + // Unsent for a day: it says so instead of going out late. + outbox[index].expire() + return + } let request: AidenBotMessageRequest if let frozen = outbox[index].request { request = frozen @@ -1038,6 +1169,13 @@ final class AidenBotSessionModel { let receipt = try await transport.sendBotMessage(botId: botID, request: request, idempotencyKey: key) if cursor == mark { 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. + if let entry = outbox[current].echoEntryID { claimedEntries.remove(entry) } + outbox[current].echoed = false + outbox[current].echoEntryID = nil + outbox[current].echoSubmissionID = nil + } if outbox[current].echoed { outbox.remove(at: current) } else { @@ -1053,7 +1191,7 @@ final class AidenBotSessionModel { purgeForAccessLoss() return } - guard aidenBotSessionFailureIsAmbiguous(error) else { + guard Self.messageFailureIsAmbiguous(error) else { // Refused outright: it never reached the Mac. outbox[current].status = .failed outbox[current].keepsKey = false @@ -1088,6 +1226,15 @@ final class AidenBotSessionModel { } } + /// 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 } @@ -1108,13 +1255,7 @@ final class AidenBotSessionModel { outbox = [] if let cacheKey { cache.purge(cacheKey) - if let outboxStore { - let previous = persistTask - persistTask = Task { - await previous?.value - await outboxStore.remove(cacheKey) - } - } + persist(.remove) } if hadUnsent { errorMessage = "That message wasn’t sent. Please try again." } } @@ -1139,7 +1280,7 @@ final class AidenBotSessionModel { key = UUID() retainedQuestion = (waitId, response, key) } - let mark = cursor + let mark = questionWrites question = nil inFlight.insert(.answerQuestion) defer { inFlight.remove(.answerQuestion) } @@ -1152,7 +1293,7 @@ final class AidenBotSessionModel { ) retainedQuestion = nil } catch { - if question == nil, cursor == mark { question = shown } + if questionWrites == mark { question = shown } if error is CancellationError { return } if !aidenBotSessionFailureIsAmbiguous(error) { retainedQuestion = nil } errorMessage = "That answer wasn’t sent. Please try again." @@ -1182,7 +1323,7 @@ final class AidenBotSessionModel { key = UUID() retainedApproval = (waitId, decision, key) } - let mark = cursor + let mark = approvalWrites approval = nil inFlight.insert(.answerApproval) defer { inFlight.remove(.answerApproval) } @@ -1200,7 +1341,7 @@ final class AidenBotSessionModel { ["approval_expired", "approval_already_resolved"].contains(body.code.rawValue) { return } - if approval == nil, cursor == mark { approval = current } + if approvalWrites == mark { approval = current } if error is CancellationError { return } errorMessage = "That answer wasn’t sent. Please try again." } @@ -1218,23 +1359,30 @@ final class AidenBotSessionModel { @discardableResult func send(_ rawText: String, whenBusy mode: AidenStreamInputMode? = nil) async -> Bool { let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) - guard canSend, (try? AidenBotMessageRequest(text: text)) != nil else { return false } - enqueue(text, mode: offersBusyModes ? mode ?? .queue : nil, retryOf: nil) - await flushTask?.value + guard canSend, (try? AidenBotMessageRequest(text: text)) != nil, + enqueue(text, mode: offersBusyModes ? mode ?? .queue : nil, retryOf: nil) else { return false } + await flushOutbox() return true } - private func enqueue(_ text: String, mode: AidenStreamInputMode?, retryOf: String?) { + static let outboxFull = "Too many messages are waiting to send. Try again once they’ve gone." + + /// 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 { + guard outbox.count < AidenBotOutboxStore.maxMessages else { + errorMessage = Self.outboxFull + return false + } outbox.append(AidenBotOutgoingMessage( key: UUID(), text: text, mode: mode, enqueuedWhileRunning: isRunning, retryOf: retryOf, - createdAt: Date(), + createdAt: now(), status: .sending )) - scheduleFlush() + return true } /// Try again on a message that couldn't be sent. A message the Mac may hold @@ -1246,11 +1394,20 @@ final class AidenBotSessionModel { outbox[index].key = UUID() outbox[index].request = nil } - outbox[index].status = .sending - outbox[index].deferred = false - outbox[index].connectionAttempts = 0 - scheduleFlush() - await flushTask?.value + 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, + keepsKey: outbox[index].keepsKey + ) + await flushOutbox() return true } @@ -1279,8 +1436,8 @@ final class AidenBotSessionModel { if let earlier = outbox.first(where: { $0.retryOf == turn.id }) { return await retrySend(earlier.id) } - enqueue(text, mode: offersBusyModes ? .queue : nil, retryOf: turn.id) - await flushTask?.value + guard enqueue(text, mode: offersBusyModes ? .queue : nil, retryOf: turn.id) else { return false } + await flushOutbox() return true } @@ -1337,6 +1494,7 @@ final class AidenBotSessionModel { retainedKeys[action] = key let previous = stateView let mark = cursor + let writes = stateWrites if let optimistic { stateView = optimistic } inFlight.insert(action) defer { inFlight.remove(action) } @@ -1346,7 +1504,8 @@ final class AidenBotSessionModel { retainedKeys[action] = nil return true } catch { - if optimistic != nil, cursor == mark { stateView = previous } + // 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." diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index dd4eeca3f..3cfb7ef71 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -40,6 +40,7 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke private var _sendKeys: [UUID] = [] private var _sendTexts: [String] = [] private var _sendModes: [AidenStreamInputMode?] = [] + private var _streamErrors: [Error] = [] private var _sendErrors: [Error] = [] private var _connectionKeys: [UUID] = [] private var _resumeResults: [Result] = [] @@ -69,6 +70,13 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke func queueSession(_ result: Result) { locked { _sessions.append(result) } } 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) } } @@ -129,16 +137,18 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke } func botSessionEvents(botId: String) -> AsyncThrowingStream { - let next = locked { () -> (events: [AidenBotSessionEvent], staysOpen: Bool) in + let (next, failure) = locked { () -> ((events: [AidenBotSessionEvent], staysOpen: Bool), Error?) in _streamRequests += 1 - return _streams.isEmpty ? ([], false) : _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 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)) + continuation.finish(throwing: failure ?? URLError(.networkConnectionLost)) } else { continuation.finish() } @@ -1937,7 +1947,8 @@ final class AidenBotSessionOptimisticTests: XCTestCase { key: AidenBotSessionKey? = nil, cache: AidenBotSessionCache? = nil, store: AidenBotOutboxStore? = nil, - retryDelays: [Duration] = [.seconds(60)] + retryDelays: [Duration] = [.seconds(60)], + now: @escaping () -> Date = { Date() } ) -> AidenBotSessionModel { let model = AidenBotSessionModel( botID: botID, @@ -1945,7 +1956,8 @@ final class AidenBotSessionOptimisticTests: XCTestCase { cacheKey: key, cache: cache ?? AidenBotSessionCache(), outboxStore: store, - retryDelays: retryDelays + retryDelays: retryDelays, + now: now ) model.activitySupported = supported return model @@ -2260,7 +2272,7 @@ final class AidenBotSessionOptimisticTests: XCTestCase { func testUnsentMessagesOutliveLeavingTheChatAndGoOutOnReturn() async throws { let transport = FakeBotSessionTransport() let cache = AidenBotSessionCache() - let key = AidenBotSessionKey(instanceId: "mac_a", botID: botID) + let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) transport.queueSession(.success(try session(.idle))) let first = model(transport, key: key, cache: cache) await first.load() @@ -2278,7 +2290,7 @@ final class AidenBotSessionOptimisticTests: XCTestCase { } func testUnsentMessagesSurviveARelaunchForADay() async throws { - let key = AidenBotSessionKey(instanceId: "mac_a", botID: botID) + let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) let (store, root) = temporaryStore() let transport = FakeBotSessionTransport() let beforeRelaunch = model(transport, key: key, store: store) @@ -2293,17 +2305,131 @@ final class AidenBotSessionOptimisticTests: XCTestCase { XCTAssertEqual(transport.sendKeys, [sentKey]) XCTAssertEqual(relaunched.outgoing.first?.status, .sent(submissionId: "17")) - // A day later the message is no longer sent. + // A day later the Mac has it: a taken message isn't kept. + let deadline = Date().addingTimeInterval(5) + while await store.load(key).first?.status != .sent(submissionId: "17") { + guard Date() < deadline else { return XCTFail("Timed out waiting for the taken message on disk") } + try await Task.sleep(for: .milliseconds(10)) + } + let later = AidenBotOutboxStore(root: root, now: { Date().addingTimeInterval(25 * 60 * 60) }) + let laterLoad = await later.load(key) + XCTAssertEqual(laterLoad, []) + + // The unsent messages stay on this device, never in a backup. + let values = try root.resourceValues(forKeys: [.isExcludedFromBackupKey]) + XCTAssertEqual(values.isExcludedFromBackup, true) + } + + func testAMessageStillUnsentAfterADaySaysCouldntSendInsteadOfGoingOutLate() async throws { + let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) + let (store, root) = temporaryStore() + let transport = FakeBotSessionTransport() + let beforeRelaunch = model(transport, key: key, store: store) + _ = await beforeRelaunch.send("Plan my week.") + try await stored(store, key, count: 1) + + // Restored a day later: shown as not sent, never dropped and never sent late. let later = AidenBotOutboxStore(root: root, now: { Date().addingTimeInterval(25 * 60 * 60) }) - let stale = model(transport, key: AidenBotSessionKey(instanceId: "mac_a", botID: botID), cache: AidenBotSessionCache(), store: later) + let relaunched = model(transport, key: key, cache: AidenBotSessionCache(), store: later) + transport.queueSession(.success(try session(.idle))) + await relaunched.load() + XCTAssertEqual(relaunched.outgoing.map { relaunched.look(of: $0) }, [.failed]) + XCTAssertEqual(transport.sendKeys, []) + let retried = await relaunched.retrySend(try XCTUnwrap(relaunched.outgoing.first).id) + XCTAssertTrue(retried) + XCTAssertEqual(relaunched.outgoing.first?.status, .sent(submissionId: "17"), "Try again sends it") + + // The same in memory: a chat left open a day doesn't send a day-old message. + var clock = Date() + let open = model(transport, now: { clock }) + _ = await open.send("Remind me at noon.") + clock = clock.addingTimeInterval(25 * 60 * 60) transport.queueSession(.success(try session(.idle))) - await stale.load() - XCTAssertEqual(stale.outgoing, []) - XCTAssertEqual(transport.sendKeys, [sentKey], "nothing older than 24 hours goes out") + await open.load() + XCTAssertEqual(open.outgoing.map { open.look(of: $0) }, [.failed]) + XCTAssertEqual(transport.sendKeys.count, 1, "only the Try again above went out") + } + + func testABadRecordCostsOnlyItselfAndAFullOutboxRefusesVisibly() 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("First") + _ = await writer.send("Second") + try await stored(store, key, count: 2) + + // Damage the first record on disk. + let files = try XCTUnwrap(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) + .compactMap { $0 as? URL }.filter { $0.pathExtension == "json" } + let file = try XCTUnwrap(files.first) + XCTAssertEqual(files.count, 1) + var envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any]) + var messages = try XCTUnwrap(envelope["messages"] as? [[String: Any]]) + messages[0]["key"] = "not a uuid" + envelope["messages"] = messages + try JSONSerialization.data(withJSONObject: envelope).write(to: file) + let survivors = await store.load(key) + XCTAssertEqual(survivors.map(\.text), ["Second"]) + + // A chat holds at most so many unsent messages; the next is refused, and says so. + let full = model(transport) + for index 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") + } } /// A working Bot's face follows the shared `glance` keyframes (docs/activity-marks.md). From a66cf3d513c0c181ec527e447b858011ffb9c931 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 15:01:02 -0400 Subject: [PATCH 18/83] Android Bots: Task 8 review folds for the activity wire - aidenProgressCapabilitiesToRequest: the opt-in grants as one table, each asked only from a Mac announcing its feature and only when not held. - whenBusy and PATCH chatActivity go only to a Mac that supports bot-activity-v1; the session transport reads that from serverInfo. - aidenBotChatActivity gates a cached detail's chatActivity on support now. - A turn leaving running drops its activity, so an optimistic Stop/Resume or the next turn never shows the run before's phase. - Fixture frames also decode through AidenBotSessionEventStream.decodeFrame; tautological grant-list assertions dropped; two-Mac grant persist/drop. Co-Authored-By: Claude Opus 5.5 --- .../features/bots/AidenBotActivity.kt | 11 ++ .../bots/AidenBotSessionController.kt | 8 +- .../features/bots/AidenBotSessionScreen.kt | 2 +- .../features/remote/AidenRemoteCoordinator.kt | 77 ++++++------ .../sbtbiswas/AidenOnTheGo/models/AidenBot.kt | 10 ++ .../networking/AidenRemoteClient.kt | 22 +++- .../AidenBotActivityContractTest.kt | 111 +++++++++++++++++- .../AidenBotRevision25ClientTest.kt | 18 +-- .../bots/AidenBotSessionControllerTest.kt | 23 ++++ 9 files changed, 222 insertions(+), 60 deletions(-) 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 index e4c94facc..fd39b2a1d 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt @@ -7,7 +7,10 @@ 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.AidenBotChatActivity import sbtbiswas.AidenOnTheGo.models.AidenBotChatActivityWording +import sbtbiswas.AidenOnTheGo.models.AidenBotDetail +import sbtbiswas.AidenOnTheGo.models.AidenServer /** * The status line's words and marks (spec §5), in §5's order. Kept in step with @@ -103,3 +106,11 @@ fun aidenBotFaceGlance(timeMs: Long): AidenBotFaceGlance { // 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?): AidenBotChatActivity? = + if (server?.supportsBotActivity == true) detail?.chatActivity else null 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 2232ac930..ea424f499 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 @@ -44,11 +44,15 @@ interface AidenBotSessionTransport { ): AidenBotRoutineProposalRespondResult } -class AidenRemoteBotSessionTransport(private val client: AidenRemoteClient) : AidenBotSessionTransport { +/** [supportsBotActivity] reads whether the Mac takes `whenBusy` now; an older Mac refuses it. */ +class AidenRemoteBotSessionTransport( + private val client: AidenRemoteClient, + private val supportsBotActivity: () -> Boolean = { false } +) : 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, whenBusy: AidenStreamInputMode?) = - client.sendBotMessage(botId, text, key, whenBusy) + client.sendBotMessage(botId, text, key, whenBusy, supportsBotActivity()) 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) 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 ca40e0f7b..262825ff4 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 @@ -139,7 +139,7 @@ fun AidenBotSessionScreen( val sessionKey = coordinator.installationStore.activeInstallation?.let { AidenBotSessionKey(it.instanceId, it.deviceId, botId) } AidenBotSessionController( botId, - AidenRemoteBotSessionTransport(cl), + AidenRemoteBotSessionTransport(cl) { coordinator.serverInfo.value?.supportsBotActivity == true }, scope, knownState, sessionKey = sessionKey, 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 3b3079dcb..e6d821e10 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 @@ -33,6 +33,41 @@ 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() + enum class AidenConnectionState { NEEDS_PAIRING, CONNECTING, @@ -237,47 +272,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) - } - } - // 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) - } - } - } + val requested = aidenProgressCapabilitiesToRequest(server, installation) if (requested.isEmpty()) return server return try { val completeCapabilities = client.updateDeviceCapabilities(requested) 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 4b7091fb2..39b76338c 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBot.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBot.kt @@ -426,6 +426,16 @@ data class AidenBotIdentityPatch( 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/networking/AidenRemoteClient.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt index d8680e452..403d3842a 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,23 @@ 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. + */ 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(), + requireNotNull(patch.forHost(supportsBotActivity)) { "This Mac doesn’t offer Chat activity." } + ), botScope = AidenBotPrivateResponseScope.Root("botDetail") ) { bytes -> val detail = decodeBotWire(AidenBotDetail.serializer(), String(bytes, Charsets.UTF_8)) @@ -1432,14 +1440,20 @@ class AidenRemoteClient( } /** Sends one turn. Control writes never auto-retry; a manual retry reuses [idempotencyKey]. */ + /** + * `POST /bots/{id}/messages`. [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 + whenBusy: AidenStreamInputMode? = null, + supportsBotActivity: Boolean = false ): AidenBotSessionSendResponse { AidenBotWire.validateIdentifier(botId, "botId", AidenRemoteProtocol.MAX_BOT_IDENTIFIER_LENGTH) - val body = botJson.encodeToString(AidenBotSessionSendRequest.serializer(), AidenBotSessionSendRequest(text, whenBusy)) + 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/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt index 16d51cab7..1a32f9d0a 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt @@ -23,6 +23,8 @@ 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 @@ -93,6 +95,14 @@ class AidenBotActivityContractTest { ) // 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), @@ -354,10 +364,73 @@ class AidenBotActivityContractTest { assertTrue(both.supportsBotActivity) assertEquals("bot-activity-v1", feature) assertEquals("bot:activity", grant.rawValue) - // The grant is negotiable like the other phone grants. - assertTrue(AidenRemoteCapability.NEGOTIABLE.contains(grant)) - assertTrue(AidenRemoteCapability.PHONE_KNOWN.contains(grant)) - assertEquals(listOf(grant), AidenRemoteCapability.PHONE_BOT_ACTIVITY) + } + + 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_ACTIVITY_FEATURE to listOf(AidenRemoteCapability.BOT_ACTIVITY), + AidenRemoteProtocol.BOT_MEMORY_FEATURE to listOf(AidenRemoteCapability.BOT_CARDS), + 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()) + assertEquals( + listOf(AidenRemoteCapability.BOT_CARDS, AidenRemoteCapability.BOT_ACTIVITY), + aidenProgressCapabilitiesToRequest(all, installationHolding()) + ) + } + + @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 { @@ -403,4 +476,34 @@ class AidenBotActivityContractTest { 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 1bc041a4c..70fc1d54a 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt @@ -357,19 +357,21 @@ class AidenBotRevision25ClientTest { 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) + 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())) - // Without a mode the body is the revision-29 body, byte for byte. - server.enqueue(ok(activity.jsonObject.getValue("response"), 202)) - client.sendBotMessage("bot_fixture_01", "Hi", UUID.randomUUID()) - assertEquals( - Json.parseToJsonElement("""{"text":"Hi"}"""), - 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/AidenBotSessionControllerTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt index 72ec0a98f..18213eaee 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 @@ -1566,4 +1566,27 @@ class AidenBotSessionControllerTest { 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) + } } From c329f2798d76c4c70ad3d0c514b871f17e768002 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 15:20:40 -0400 Subject: [PATCH 19/83] Android Bots: status line, Steer/Queue split button, Queued bubbles and glancing faces The chat draws the R14 outbox (Sending, Queued, Couldn't send with Try again and remove at 48dp), the cached transcript or still/shimmering skeletons with a Connecting / Try again hint, and the status line with a 2 s throttled word-only announcement that resets when the line hides. Waiting bubbles use the opaque primaryContainer pair (>=4.5:1), not alpha. Bots home replaces the green dot with glancing faces; Reduce Motion, battery saver and photos wear the glance badge (R19). Co-Authored-By: Claude Opus 5.5 --- .../features/bots/AidenBotActivity.kt | 339 +++++++++++++++++- .../bots/AidenBotCanonicalAvatarView.kt | 43 ++- .../bots/AidenBotSemanticAvatarView.kt | 44 ++- .../features/bots/AidenBotSessionScreen.kt | 115 ++++-- .../features/bots/AidenBotsHomeScreen.kt | 20 +- .../features/chat/AidenComposerView.kt | 2 +- .../AidenOnTheGo/ui/theme/AidenSkeleton.kt | 8 +- android/app/src/main/res/values/strings.xml | 5 + .../bots/AidenBotChatPresentationTest.kt | 75 ++++ .../bots/AidenBotSessionControllerTest.kt | 82 +++++ 10 files changed, 680 insertions(+), 53 deletions(-) create mode 100644 android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatPresentationTest.kt 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 index fd39b2a1d..a706f4455 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt @@ -1,16 +1,49 @@ package sbtbiswas.AidenOnTheGo.features.bots import androidx.annotation.StringRes -import androidx.compose.runtime.Composable +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.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.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 @@ -114,3 +147,307 @@ fun aidenBotFaceGlance(timeMs: Long): AidenBotFaceGlance { */ fun aidenBotChatActivity(server: AidenServer?, detail: AidenBotDetail?): AidenBotChatActivity? = if (server?.supportsBotActivity == true) 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 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 if (!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 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) + +/** + * 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, announced at most once every 2 seconds; 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) } + 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() + val spoken = announced?.text ?: shownWord + // 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) + .clearAndSetSemantics { + liveRegion = LiveRegionMode.Polite + contentDescription = spoken + } + ) { + 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 = AidenTheme.palette.secondary, + 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) + when (bubble.caption) { + AidenBotPendingCaption.FAILED -> AidenBotPendingBubbleView(message.text, faded = bubble.faded) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.ErrorOutline, contentDescription = null, tint = palette.danger, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.bot_session_send_failed), style = MaterialTheme.typography.labelMedium, color = palette.secondary) + Text(" ·", style = MaterialTheme.typography.labelMedium, color = palette.secondary, modifier = Modifier.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) palette.accent else palette.secondary) + } + IconButton(onClick = onRemove, modifier = Modifier.size(AidenUi.MinimumTouchTarget)) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.bot_session_remove_message), tint = palette.secondary, 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). */ +@Composable +fun AidenBotConnectionHintRow(hint: AidenBotConnectionHint, onTryAgain: () -> Unit) { + val palette = AidenTheme.palette + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(min = 24.dp) + ) { + when (hint) { + AidenBotConnectionHint.CONNECTING -> Text( + stringResource(R.string.bot_session_connecting), + style = MaterialTheme.typography.labelMedium, + color = palette.secondary + ) + AidenBotConnectionHint.UNREACHABLE -> { + Text( + stringResource(R.string.bot_session_load_failed), + style = MaterialTheme.typography.labelMedium, + color = palette.secondary, + 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 = palette.accent) + } + } + } + } +} 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/AidenBotSemanticAvatarView.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSemanticAvatarView.kt index db2324432..de803676c 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,9 @@ 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.aidenReduceMotion /** * What a Bot's mark looks like: a colour and a shape. Every Bot shares one fixed eye @@ -68,12 +74,36 @@ 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 + clockMs.longValue = frameMs - startMs + } + } + } + Box( modifier = modifier .size(size) @@ -85,17 +115,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/AidenBotSessionScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt index 262825ff4..8d9cfcded 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 @@ -43,6 +43,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch 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 @@ -142,6 +143,7 @@ fun AidenBotSessionScreen( AidenRemoteBotSessionTransport(cl) { coordinator.serverInfo.value?.supportsBotActivity == true }, scope, knownState, + activitySupported = serverInfo?.supportsBotActivity == true, sessionKey = sessionKey, store = coordinator.botSessionStore ) @@ -151,7 +153,28 @@ fun AidenBotSessionScreen( onDispose { controller.stopFollowing() } } val ui by controller.state.collectAsStateWithLifecycle() + 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. Only a Mac that + // offers Chat activity now decides it, and the feed never waits for this read. + val botDetails by coordinator.botCache.botDetails.collectAsStateWithLifecycle() + val chatActivity = aidenBotChatActivity(serverInfo, botDetails[botId]) + LaunchedEffect(controller, chatActivity) { + controller.setWording(chatActivity?.wording ?: AidenBotChatActivityWording.FOLLOWS) + } + LaunchedEffect(controller) { + try { + coordinator.botCache.putBotDetail(cl.bot(botId)) + } 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. @@ -202,11 +225,16 @@ 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 - LaunchedEffect(entries.size, partial, ui.isInterrupted, ui.needsModel) { + LaunchedEffect( + entries.size, partial, ui.isInterrupted, ui.needsModel, + ui.queued.size, outgoing.size, ui.session?.activity?.phase + ) { val count = listState.layoutInfo.totalItemsCount if (count > 0) listState.animateScrollToItem(count - 1) } @@ -283,13 +311,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) @@ -337,6 +367,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( @@ -379,15 +421,24 @@ fun AidenBotSessionScreen( } } } + aidenBotConnectionHint(ui, System.currentTimeMillis())?.let { hint -> + AidenBotConnectionHintRow(hint, 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 = "" scope.launch { - if (controller.send(text)) draft = "" + if (!controller.send(text, mode) && draft.isEmpty()) draft = text } } ) @@ -396,11 +447,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), @@ -409,11 +461,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) } } @@ -687,6 +739,9 @@ private fun AidenBotSessionComposer( onDraftChange: (String) -> Unit, placeholder: String, enabled: Boolean, + busy: Boolean, + busyMode: AidenStreamInputMode, + onPickBusyMode: (AidenStreamInputMode) -> Unit, onSend: () -> Unit ) { val palette = AidenTheme.palette @@ -710,14 +765,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..c5595adb2 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 @@ -534,27 +535,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)) { 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..6133b7770 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, 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..cd4fe0e23 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 @@ -36,18 +36,20 @@ import sbtbiswas.AidenOnTheGo.R /** * 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 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ea185dbc5..0f4378f27 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -643,6 +643,11 @@ %1$s · %2$s Queued Sends when the current reply finishes + Sending… + Couldn’t send + Try again + 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. 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..73fbd99d5 --- /dev/null +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatPresentationTest.kt @@ -0,0 +1,75 @@ +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 + +/** 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 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/AidenBotSessionControllerTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt index 18213eaee..12ad523e2 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 @@ -51,8 +51,11 @@ 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 } @@ -1382,6 +1385,85 @@ class AidenBotSessionControllerTest { 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 aCachedChatShowsItsTranscriptWithConnectingAndAMissingBotShowsNeither() = runTest { + val sessions = AidenBotSessionStore(null) + 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 aDroppedFeedSaysReconnectingOnceOnTheLineAndConnectingWhenIdle() = 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) From 2da9793d8a71e54b09eb68761076a39b76849ce5 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 15:30:38 -0400 Subject: [PATCH 20/83] iOS Bots: Chat activity recovers from conflicts, pauses previews, opens offline Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Bots/AidenBotAdvancedView.swift | 299 +++++++++++++++--- .../AidenBotSessionTests.swift | 198 +++++++++++- 2 files changed, 434 insertions(+), 63 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift index b47b63891..f68ab54b6 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift @@ -29,6 +29,10 @@ struct AidenBotAdvancedView: View { @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 + /// A Chat activity pick saved here, not yet reported to the screens behind Advanced. + @State private var chatActivityChanged = false private var sessionIdentity: AidenBotCustomAccessSessionIdentity { AidenBotCustomAccessSessionIdentity(coordinator: coordinator) @@ -82,6 +86,13 @@ struct AidenBotAdvancedView: View { reset(for: expectedSession) await load(for: expectedSession) } + .onDisappear { + // Closing Advanced (not opening Chat activity) reports a saved pick once. + if chatActivityChanged, !isShowingChatActivity, let baselineBot { + chatActivityChanged = false + onSaved(baselineBot) + } + } .alert( "Couldn’t Save", isPresented: Binding( @@ -134,6 +145,9 @@ struct AidenBotAdvancedView: View { } } + /// Editing waits for a connection and for a save; Chat activity has its own rules. + private var isLocked: Bool { isSaving || !canWrite } + private func form(_ catalog: AidenBotCapabilityCatalog) -> some View { Form { if !canWrite { @@ -142,20 +156,31 @@ struct AidenBotAdvancedView: View { .foregroundStyle(palette.secondary) } } - modelSection(catalog) - accessSection(catalog) + modelSection(catalog).disabled(isLocked) + accessSection(catalog).disabled(isLocked) chatActivitySection - 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) + 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 + ) + } + } } private func modelSection(_ catalog: AidenBotCapabilityCatalog) -> some View { @@ -213,39 +238,76 @@ struct AidenBotAdvancedView: View { } } - /// Revision 30: hidden unless the Mac announces `bot-activity-v1` and this phone holds `bot:activity`. + /// 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 coordinator.server?.supportsBotActivity == true, let baselineBot { + if aidenBotShowsChatActivity(server: coordinator.server, hasBaseline: baselineBot != nil), let baselineBot { Section { - NavigationLink { - AidenBotChatActivityView( - bot: baselineBot, - canWrite: coordinator.installationStore.activeInstallation?.canWriteBots == true, - 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 - // Advanced's own Save, and the screens behind it, move to the newest revision. - self.baselineBot = saved - onSaved(saved) - }) - ) + Button { + openChatActivity(baselineBot) } label: { - LabeledContent(AidenBotChatActivityCopy.title, value: AidenBotChatActivityCopy.summary(baselineBot.chatActivity)) + 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()) } - .disabled(isDirty) + .buttonStyle(.plain) + .disabled(isDirty || isSaving) } 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 + chatActivityModel = AidenBotChatActivityModel( + bot: bot, + save: { next, revision in + let client = try coordinator.remoteClient(for: try coordinator.requestContext()) + return try await client.updateBotIdentity( + id: botID, + revision: revision, + patch: try AidenBotIdentityPatch(chatActivity: next) + ) + }, + refresh: { + let client = try coordinator.remoteClient(for: try coordinator.requestContext()) + return try await client.bot(id: botID) + }, + onSaved: { saved in + // Advanced's own Save, and its summary row, move to the newest revision. + // The screens behind hear about it once, when Advanced closes. + baselineBot = saved + chatActivityChanged = true + cacheDetail(saved) + }, + waitForConnection: { hold in await aidenBotWaitForConnection(coordinator, hold: hold) } + ) + } + isShowingChatActivity = true + } + + private func cacheDetail(_ detail: AidenBotDetail) { + guard let context = try? coordinator.requestContext() 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 @@ -377,6 +439,9 @@ struct AidenBotAdvancedView: View { saveError = nil activeAttempt = nil isConfirmingDiscard = false + chatActivityModel = nil + isShowingChatActivity = false + chatActivityChanged = false } @MainActor @@ -484,6 +549,7 @@ struct AidenBotAdvancedView: View { self.baselineBot = latest } guard isCurrent(attempt) else { return } + chatActivityChanged = false onSaved(latest) dismiss() } catch is CancellationError { @@ -510,6 +576,7 @@ struct AidenBotAdvancedView: View { self.catalog = refreshedCatalog self.draft = rebasedDraft if (try? rebasedDraft.isSatisfied(by: authoritative, catalog: refreshedCatalog)) == true { + chatActivityChanged = false onSaved(authoritative) dismiss() } else { @@ -677,6 +744,32 @@ struct AidenBotModelListView: View { // 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. +func aidenBotShowsChatActivity(server: AidenServer?, hasBaseline: Bool) -> Bool { + hasBaseline && server?.supportsBotActivity == true +} + +/// 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" @@ -733,21 +826,28 @@ 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. + /// 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] - /// 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"] + private static let runningLabels = ["Reading 1 file…", "Searching the web…", "Editing 1 file…"] + + /// 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, +) @@ -763,19 +863,33 @@ enum AidenBotChatActivityPreview { 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) + 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: [], 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) + 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) } } } +/// A save that outran its limit; the pick returns to the saved choice. +struct AidenBotChatActivityTimeout: Error {} +/// The Mac stayed unreachable for the whole hold. +struct AidenBotChatActivityUnreachable: Error {} + /// 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, on the revision that save returned. +/// 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 { @@ -788,19 +902,33 @@ final class AidenBotChatActivityModel { @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)? + @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. init( bot: AidenBotDetail, save: @escaping @MainActor (AidenBotChatActivity, String) async throws -> AidenBotDetail, - onSaved: @escaping @MainActor (AidenBotDetail) -> Void + refresh: @escaping @MainActor () async throws -> AidenBotDetail, + onSaved: @escaping @MainActor (AidenBotDetail) -> Void, + waitForConnection: (@MainActor (Duration) async -> Bool)? = nil, + 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.connectionHold = connectionHold + self.requestLimit = requestLimit } func pick(_ next: AidenBotChatActivity) async { @@ -816,14 +944,28 @@ final class AidenBotChatActivityModel { var target = next while true { do { - let detail = try await save(target, revision) + if let waitForConnection, await waitForConnection(connectionHold) == false { + throw AidenBotChatActivityUnreachable() + } + let save = save + let sending = target + let sentRevision = revision + let detail = try await Self.within(requestLimit) { try await save(sending, sentRevision) } revision = detail.revision saved = detail.chatActivity ?? target onSaved(detail) } catch { + pending = nil + if error is CancellationError { + value = saved + return + } + if aidenBotIsRevisionConflict(error) { + await adoptTheMacsChoice() + } pending = nil value = saved - if !(error is CancellationError) { errorMessage = AidenBotChatActivityCopy.saveFailed } + errorMessage = AidenBotChatActivityCopy.saveFailed return } guard let following = pending else { break } @@ -833,6 +975,31 @@ final class AidenBotChatActivityModel { } value = saved } + + /// The Bot changed on the Mac: take its revision and choice, so the next pick can save. + private func adoptTheMacsChoice() async { + let refresh = refresh + guard let fresh = try? await Self.within(requestLimit, { try await refresh() }) else { return } + revision = fresh.revision + saved = fresh.chatActivity ?? .standard + onSaved(fresh) + } + + nonisolated private static func within( + _ limit: Duration, + _ operation: @escaping @MainActor () async throws -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: limit) + throw AidenBotChatActivityTimeout() + } + defer { group.cancelAll() } + guard let first = try await group.next() else { throw AidenBotChatActivityTimeout() } + return first + } + } } struct AidenBotChatActivityView: View { @@ -862,23 +1029,27 @@ struct AidenBotChatActivityView: View { .listRowBackground(palette.raised) } Section(AidenBotChatActivityCopy.levelTitle) { - ForEach(AidenBotChatActivityLevel.allCases, id: \.self) { level in + 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) { - ForEach(AidenBotChatActivityWording.allCases, id: \.self) { wording in + 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)) } } } @@ -907,12 +1078,21 @@ struct AidenBotChatActivityView: View { } } + /// 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 { @@ -939,19 +1119,22 @@ struct AidenBotChatActivityView: View { .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 ? [.isButton, .isSelected] : .isButton) - .accessibilityHint(detail) + .accessibilityAddTraits(selected ? .isSelected : []) } } -/// A small chat replaying one scripted turn. It holds its still frame under Reduce Motion. +/// 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 @@ -959,19 +1142,24 @@ struct AidenBotChatActivityPreviewView: View { @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: still)) { context in + TimelineView(.animation(minimumInterval: 0.2, paused: paused)) { context in let index = still - ? AidenBotChatActivityPreview.stillFrame + ? 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)) + .onScrollVisibilityChange(threshold: 0.05) { onScreen = $0 } .accessibilityHidden(true) } @@ -986,17 +1174,24 @@ struct AidenBotChatActivityPreviewView: View { .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) + 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: 14, active: !still, color: palette.secondary) + AidenActivityMark(mark: shown.mark, size: markSize, active: !paused, color: palette.secondary) Text(shown.word).font(.caption) } .foregroundStyle(palette.secondary) diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index 3cfb7ef71..cf317197b 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -2862,11 +2862,35 @@ final class AidenBotChatActivityTests: XCTestCase { 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) + } + + /// 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, + 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, connectionHold: connectionHold, requestLimit: requestLimit + ) + } + 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 + 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) }) @@ -2885,11 +2909,11 @@ final class AidenBotChatActivityTests: XCTestCase { 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 + 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) - }, 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.") @@ -2901,14 +2925,43 @@ final class AidenBotChatActivityTests: XCTestCase { 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.errorMessage, "Aiden couldn’t save that choice.") + 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.errorMessage, "Aiden couldn’t save that choice.") + } + func testAPickShowsAtOnceAndASecondTapWhileSavingIsNeitherLostNorSentOnAStaleRevision() async throws { var calls: [(AidenBotChatActivity, String)] = [] var release: CheckedContinuation? - let model = AidenBotChatActivityModel(bot: try detail(revision: "rev_1", nil), save: { next, revision in + let model = try model(save: { next, revision in calls.append((next, revision)) if calls.count == 1 { await withCheckedContinuation { release = $0 } } return try self.detail(revision: "rev_\(calls.count + 1)", next) - }, onSaved: { _ in }) + }) let first = Task { await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) } while release == nil { await Task.yield() } @@ -2927,19 +2980,105 @@ final class AidenBotChatActivityTests: XCTestCase { XCTAssertNil(model.errorMessage) } + func testThreeTapsDuringOneSaveSendTheFirstAndTheLastOnly() async throws { + var calls: [AidenBotChatActivity] = [] + var release: CheckedContinuation? + let model = try model(save: { next, _ in + calls.append(next) + if calls.count == 1 { await withCheckedContinuation { release = $0 } } + return try self.detail(revision: "rev_\(calls.count + 1)", next) + }) + let first = Task { await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) } + while release == nil { await Task.yield() } + await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) + await model.pick(AidenBotChatActivity(level: .detailed, wording: .thinking)) + await model.pick(AidenBotChatActivity(level: .quiet, wording: .thinking)) + release?.resume() + 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] = [] + var release: CheckedContinuation? + let original = AidenBotChatActivity(level: .quiet, wording: .follows) + let model = try model(original, save: { next, _ in + calls.append(next) + if calls.count == 1 { await withCheckedContinuation { release = $0 } } + return try self.detail(revision: "rev_\(calls.count + 1)", next) + }) + let first = Task { await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) } + while release == nil { await Task.yield() } + await model.pick(original) + XCTAssertEqual(model.value, original, "the page already shows the choice they went back to") + release?.resume() + 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 = AidenBotChatActivityModel(bot: try detail(revision: "rev_1", nil), save: { next, _ in + let model = try model(save: { next, _ in count += 1 if count == 2 { throw SaveFailure() } return try self.detail(revision: "rev_2", next) - }, onSaved: { _ in }) + }) 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.errorMessage, "Aiden couldn’t save that choice.") + } + + 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.errorMessage, "Aiden couldn’t save that choice.") + + 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 @@ -2949,8 +3088,16 @@ final class AidenBotChatActivityTests: XCTestCase { 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, []) - XCTAssertEqual(AidenBotChatActivityPreview.frame(index: searching, level: .detailed).steps.count, searching, "Detailed lists each finished step while it works") + // 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) @@ -2966,12 +3113,41 @@ final class AidenBotChatActivityTests: XCTestCase { 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") + } + + 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]), hasBaseline: true)) + XCTAssertFalse(aidenBotShowsChatActivity(server: server(features: [], grants: [.botActivity]), hasBaseline: true), "an older Mac") + XCTAssertFalse(aidenBotShowsChatActivity(server: server(features: announced, grants: []), hasBaseline: true), "no grant") + XCTAssertFalse(aidenBotShowsChatActivity(server: nil, hasBaseline: true), "no server read yet") + XCTAssertFalse(aidenBotShowsChatActivity(server: server(features: announced, grants: [.botActivity]), hasBaseline: false), "no Bot detail yet") + } } From 77e8f7aa75415d70961ba49f4a06f52af00b55eb Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 15:31:30 -0400 Subject: [PATCH 21/83] Android Bots: frozen requests, no silent drop on exhaustion, outbox read off main - The session transport sends whenBusy exactly as the controller froze it, so a retry under the same key is byte-identical. - Exhausted retries remove a message only when an untagged entry stands for it; an entry naming another submission leaves it Couldn't send, key kept, claim given back, and never settles a failed message. - The outbox is read on the store's io thread from start(); deliveries wait for it and restored messages go first. Writes made before the read wait in memory and merge. Unpair keeps purged chats read and empty. - stopFollowing cancels the delivery under way; a refused store write ends sending. AtomicFile (fsync before rename); enqueue refused past the byte budget; any activity after a Resume tap clears Resume. - Typed exception for a chatActivity-only patch to an older Mac. Co-Authored-By: Claude Opus 5.5 --- .../features/bots/AidenBotOutbox.kt | 117 ++++++--- .../bots/AidenBotSessionController.kt | 150 ++++++++--- .../features/bots/AidenBotSessionScreen.kt | 2 +- .../networking/AidenRemoteClient.kt | 9 +- .../AidenBotActivityContractTest.kt | 4 + .../AidenBotRevision25ClientTest.kt | 7 + .../bots/AidenBotSessionControllerTest.kt | 248 +++++++++++++++--- 7 files changed, 433 insertions(+), 104 deletions(-) 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 index df6e94bfe..dd74b726c 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt @@ -1,5 +1,7 @@ package sbtbiswas.AidenOnTheGo.features.bots +import androidx.core.util.AtomicFile +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -12,6 +14,7 @@ import java.security.MessageDigest import java.util.UUID import java.util.concurrent.Executor import java.util.concurrent.Executors +import kotlin.coroutines.resume /** * Names one Bot chat across controllers and launches: its Mac, this pairing's device and the @@ -131,7 +134,9 @@ class AidenBotSessionStore( private val root: File?, private val io: Executor = defaultIo, private val nowMs: () -> Long = System::currentTimeMillis, - private val report: (String) -> Unit = {} + private val report: (String) -> Unit = {}, + /** The largest outbox file a later launch reads back; one past it is refused at enqueue. */ + private val maxBytes: Int = MAX_BYTES ) { private val transcripts = LinkedHashMap>(CAPACITY, 0.75f, true) private val outboxes = HashMap>() @@ -147,43 +152,83 @@ class AidenBotSessionStore( while (transcripts.size > CAPACITY) transcripts.remove(transcripts.keys.first()) } - /** The chat's unsent messages, oldest first, now written by [owner] only. */ + /** + * 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 { + fun adoptOutbox(key: AidenBotSessionKey, owner: Any): List? { owners[key] = owner - if (read.add(key)) { - val stored = load(key) - if (stored.isNotEmpty()) outboxes[key] = stored - } - return outboxes[key].orEmpty() + return if (key in read) outboxes[key].orEmpty() else null } - /** Keeps [messages] when [owner] writes this chat; false when another controller does, or after an unpair. */ + /** + * 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. Disk reads, writes and purges all run in order on [io], + * so a purge that came first leaves nothing to read. Empty once [owner] no longer writes it. + */ + suspend fun restoreOutbox(key: AidenBotSessionKey, owner: Any): List = + suspendCancellableCoroutine { continuation -> + io.execute { + val messages = synchronized(this) { + when { + owners[key] !== owner -> emptyList() + read.add(key) -> { + // What was sent before this read waited in memory; it follows the older disk copy. + val disk = load(key) + val known = disk.mapTo(HashSet()) { it.key } + val pending = outboxes[key].orEmpty().filterNot { it.key in known } + val merged = disk + pending + if (merged.isEmpty()) outboxes.remove(key) else outboxes[key] = merged + if (pending.isNotEmpty()) write(key, merged.map(Record::of)) + merged + } + else -> outboxes[key].orEmpty() + } + } + continuation.resume(messages) + } + } + + /** + * 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 - val records = messages.map(Record::of) - io.execute { write(key, records) } + 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 - io.execute { fileFor(key)?.delete() } + io.execute { fileFor(key)?.let { AtomicFile(it).delete() } } } - /** The Mac was unpaired: every chat of it goes, and no controller still open may write again. */ + /** + * 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 } - read.removeAll { it.instanceId == instanceId } val dir = root?.let { File(it, digest(instanceId)) } io.execute { dir?.deleteRecursively() } } @@ -268,17 +313,20 @@ class AidenBotSessionStore( */ private fun load(key: AidenBotSessionKey): List { val file = fileFor(key) ?: return emptyList() - if (!file.exists()) return emptyList() + 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 emptyList() val envelope = try { - if (file.length() > MAX_BYTES) throw IllegalStateException("too large") - json.decodeFromString(Envelope.serializer(), file.readText(Charsets.UTF_8)) + val bytes = atomic.readFully() + if (bytes.size > 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=dropped") - file.delete() + atomic.delete() return emptyList() } val readable = envelope.messages.mapNotNull { element -> @@ -307,27 +355,34 @@ class AidenBotSessionStore( return messages } + 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 + val atomic = AtomicFile(file) + if (records.isEmpty()) { + atomic.delete() + return + } + var stream: java.io.FileOutputStream? = null try { - if (records.isEmpty()) { - file.delete() - return - } - val envelope = Envelope(1, key.instanceId, key.deviceId, key.botId, records.map { json.encodeToJsonElement(it) }) + val data = encode(key, records) file.parentFile?.mkdirs() - val temp = File(file.parentFile, "${file.name}.tmp") - temp.writeText(json.encodeToString(Envelope.serializer(), envelope), Charsets.UTF_8) - if (!temp.renameTo(file)) { - temp.delete() - report("event=bot_outbox_write outcome=failed") - } + stream = atomic.startWrite() + stream.write(data) + atomic.finishWrite(stream) } catch (_: Exception) { + stream?.let { atomic.failWrite(it) } report("event=bot_outbox_write outcome=failed") } } - private fun fileFor(key: AidenBotSessionKey): File? = root?.let { + /** 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") } @@ -336,7 +391,7 @@ class AidenBotSessionStore( /** The most messages one chat may hold waiting; another is refused, visibly. */ const val MAX_MESSAGES = 20 const val CAPACITY = 16 - private const val MAX_BYTES = 4_000_000L + const val MAX_BYTES = 4_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 } } 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 ea424f499..995d4d83a 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 @@ -44,15 +44,16 @@ interface AidenBotSessionTransport { ): AidenBotRoutineProposalRespondResult } -/** [supportsBotActivity] reads whether the Mac takes `whenBusy` now; an older Mac refuses it. */ -class AidenRemoteBotSessionTransport( - private val client: AidenRemoteClient, - private val supportsBotActivity: () -> Boolean = { false } -) : AidenBotSessionTransport { +/** + * 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, whenBusy: AidenStreamInputMode?) = - client.sendBotMessage(botId, text, key, whenBusy, supportsBotActivity()) + 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) @@ -403,6 +404,9 @@ class AidenBotSessionController( private var flushJob: Job? = null private var backoffJob: Job? = null private var downJob: Job? = null + private var restoreJob: Job? = null + /** What a previous launch left on disk has been read (or there is nothing to read): deliveries wait for it. */ + private var restored: Boolean private var released = false private var isFlushing = false private val inFlight = mutableSetOf() @@ -419,10 +423,13 @@ class AidenBotSessionController( private var stopStartedAt: Long? = null init { - val now = nowMs() val cached = sessionKey?.let { store?.transcript(it) }.orEmpty() - val stored = sessionKey?.let { store?.adoptOutbox(it, this) }.orEmpty() - val adopted = stored.map { it.adopted(now) } + // 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, @@ -432,7 +439,7 @@ class AidenBotSessionController( ) ) state = _state.asStateFlow() - if (adopted != stored && sessionKey != null) store?.storeOutbox(sessionKey, this, adopted) + if (held != null && adopted != held && sessionKey != null) store?.storeOutbox(sessionKey, this, adopted) } fun setActivitySupported(supported: Boolean) { @@ -443,14 +450,18 @@ class AidenBotSessionController( _state.update { it.copy(wording = wording) } } - /** Starts loading and following the session; safe to call once per screen. */ + /** 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 and retry timers stop, and unsent messages stay for the next visit. */ + /** + * 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() @@ -458,10 +469,33 @@ class AidenBotSessionController( flushJob?.cancel() backoffJob?.cancel() downJob?.cancel() + restoreJob?.cancel() + } + + /** + * 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 + restoreJob = scope.launch { + val stored = store.restoreOutbox(key, this@AidenBotSessionController) + val now = nowMs() + val known = stored.mapTo(HashSet()) { it.key } + restored = true + setOutbox(stored.map { it.adopted(now) } + _state.value.outbox.filterNot { it.key in known }, force = true) + 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 = load(authoritative = false) + suspend fun refetch(): Boolean { + ensureRestored() + return load(authoritative = false) + } private suspend fun load(authoritative: Boolean): Boolean { return try { @@ -565,7 +599,8 @@ class AidenBotSessionController( /** * 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. + * `startedAt`, or a first activity after a Stop pressed before any) has nothing to stop. Any + * activity after a Resume tap says what the resumed run is doing. */ private fun activityArrived(next: AidenBotSessionActivity?) { val stopRun = stopStartedAt @@ -574,7 +609,7 @@ class AidenBotSessionController( (next != null && (stopRun == null || next.startedAt != stopRun)) current.copy( stopIntent = if (clearStop) AidenBotTurnIntent.NONE else current.stopIntent, - resumeIntent = if (current.resumeIntent == AidenBotTurnIntent.ACKNOWLEDGED) AidenBotTurnIntent.NONE else current.resumeIntent + resumeIntent = AidenBotTurnIntent.NONE ) } } @@ -665,18 +700,25 @@ class AidenBotSessionController( _state.update { it.copy(actionError = null) } val mode = if (current.offersBusyModes) whenBusy ?: AidenStreamInputMode.QUEUE else null if (!enqueue(trimmed, mode, retryOf = null)) return false - flushOutbox() + ensureRestored() + flushNow() return true } - /** Adds a message, unless the chat already holds as many as it can keep: then it says so. */ + /** + * 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 - if (current.outbox.size >= AidenBotSessionStore.MAX_MESSAGES) { + val next = current.outbox + AidenBotOutgoingMessage.create(text, mode, current.isRunning, retryOf, nowMs()) + val fits = next.size <= AidenBotSessionStore.MAX_MESSAGES && + (sessionKey == null || store == null || store.fits(sessionKey, next)) + if (!fits) { _state.update { it.copy(actionError = OUTBOX_FULL) } return false } - setOutbox(current.outbox + AidenBotOutgoingMessage.create(text, mode, current.isRunning, retryOf, nowMs())) + setOutbox(next) return true } @@ -697,7 +739,7 @@ class AidenBotSessionController( queuedId = null ) setOutbox(_state.value.outbox.map { if (it.id == id) again else it }) - flushOutbox() + flushNow() return true } @@ -719,15 +761,25 @@ class AidenBotSessionController( 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 - flushOutbox() + flushNow() return true } - private fun setOutbox(next: List) { - if (_state.value.outbox == next) return + /** + * 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 - store?.storeOutbox(key, this, next) + val store = store ?: return + if (!store.storeOutbox(key, this, next)) { + released = true + backoffJob?.cancel() + } } /** Replaces the message with [key]; a null result removes it. */ @@ -808,9 +860,12 @@ class AidenBotSessionController( 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. + // 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)) continue + if (theirs != null && + (message.submissionId != null || theirs in owned || message.status == AidenBotOutgoingStatus.FAILED) + ) continue byText = sent } return byText @@ -822,16 +877,28 @@ class AidenBotSessionController( scheduleFlush() } - /** Sends the outbox beside whatever called this (a frame, a snapshot, a backoff), never holding up the feed. */ + /** + * 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() } } - /** The oldest unsettled message, unless it waits for a retry: nothing overtakes it, so messages arrive in order. */ + /** 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) return null + 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 } @@ -858,8 +925,9 @@ class AidenBotSessionController( return } if (message.isExpired(nowMs())) { - // Unsent for a day: it says so instead of going out late. - updateMessage(key) { it.expired() } + // 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( @@ -914,9 +982,23 @@ class AidenBotSessionController( updateMessage(key) { it.copy(keepsKey = true, connectionAttempts = attempts, deferred = true) } scheduleBackoff(attempts) } else { - // The Mac's transcript may already show it; otherwise "Couldn't send · Try again", key kept. updateMessage(key) { - if (it.echoed) null else it.copy(status = AidenBotOutgoingStatus.FAILED, keepsKey = true, connectionAttempts = attempts) + 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 { @@ -1274,7 +1356,7 @@ class AidenBotSessionController( const val UNREACHABLE = "Aiden couldn’t reach your Mac. Try again." const val ANSWER_FAILED = "Aiden couldn’t send that answer. Try again." const val ACCESS_LOST = "That message wasn’t sent. Please try again." - const val OUTBOX_FULL = "Too many messages are waiting to send. Try again once they’ve gone." + const val OUTBOX_FULL = "Too many messages are waiting or didn’t send. Send or remove some, then try again." } } 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 8d9cfcded..3f473405e 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 @@ -140,7 +140,7 @@ fun AidenBotSessionScreen( val sessionKey = coordinator.installationStore.activeInstallation?.let { AidenBotSessionKey(it.instanceId, it.deviceId, botId) } AidenBotSessionController( botId, - AidenRemoteBotSessionTransport(cl) { coordinator.serverInfo.value?.supportsBotActivity == true }, + AidenRemoteBotSessionTransport(cl), scope, knownState, activitySupported = serverInfo?.supportsBotActivity == true, 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 403d3842a..c1486164b 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt @@ -1380,7 +1380,8 @@ class AidenRemoteClient( /** * `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. + * 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, @@ -1393,7 +1394,7 @@ class AidenRemoteClient( ifMatchRevision = revision, bodyJson = botJson.encodeToString( AidenBotIdentityPatch.serializer(), - requireNotNull(patch.forHost(supportsBotActivity)) { "This Mac doesn’t offer Chat activity." } + patch.forHost(supportsBotActivity) ?: throw AidenBotContractException.InvalidCombination("chat activity unsupported") ), botScope = AidenBotPrivateResponseScope.Root("botDetail") ) { bytes -> @@ -1439,9 +1440,9 @@ class AidenRemoteClient( } } - /** Sends one turn. Control writes never auto-retry; a manual retry reuses [idempotencyKey]. */ /** - * `POST /bots/{id}/messages`. [whenBusy] goes out only when [supportsBotActivity] (the Mac + * `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( diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt index 1a32f9d0a..a75e9c47e 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt @@ -382,6 +382,10 @@ class AidenBotActivityContractTest { val rows = listOf( AidenRemoteProtocol.BOT_ACTIVITY_FEATURE to listOf(AidenRemoteCapability.BOT_ACTIVITY), 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) diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt index 70fc1d54a..3483e7387 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotRevision25ClientTest.kt @@ -364,6 +364,13 @@ class AidenBotRevision25ClientTest { 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)) 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 12ad523e2..9e349d2df 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 @@ -21,7 +21,9 @@ 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 @@ -32,6 +34,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")) @@ -1234,14 +1239,14 @@ class AidenBotSessionControllerTest { assertEquals(AidenBotSessionState.IDLE, controller.state.value.state) } - private fun store(root: java.io.File, clock: () -> Long) = - AidenBotSessionStore(root, io = { it.run() }, nowMs = clock) + 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 = kotlin.io.path.createTempDirectory("bot-outbox").toFile() + val root = tempFolder.newFolder() var clock = started val transport = FakeTransport(runningSession) val first = store(root, { clock }) @@ -1249,36 +1254,128 @@ class AidenBotSessionControllerTest { runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = first, nowMs = { clock } ) assertTrue(leaving.send("Plan lunch")) - assertTrue(leaving.send("And dinner")) + 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: the same messages, still unsent. + // 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", "And dinner"), back.state.value.outgoing.map { it.text }) + 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 them from disk. + // 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 } ) - val restored = relaunched.state.value.outgoing - assertEquals(listOf("Plan lunch", "And dinner"), restored.map { it.text }) - assertEquals(back.state.value.outbox.map { it.key }, restored.map { it.key }) + 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(restored.map { it.key }, transport.sends.map { it.second }) - root.deleteRecursively() + 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 dropped", theirs.exists()) + other.stopFollowing() + } + + @Test + fun aControllerThatLostTheChatToAnotherStopsSending() = runTest { + val sessions = AidenBotSessionStore(null) + 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) + 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(AidenBotSessionController.OUTBOX_FULL, controller.state.value.actionError) + 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 = kotlin.io.path.createTempDirectory("bot-outbox").toFile() + val root = tempFolder.newFolder() var clock = started val transport = FakeTransport(runningSession) val controller = AidenBotSessionController( @@ -1286,6 +1383,7 @@ class AidenBotSessionControllerTest { ) 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 } @@ -1295,11 +1393,11 @@ class AidenBotSessionControllerTest { 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())) - later.refetch() - runCurrent() 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. @@ -1310,38 +1408,40 @@ class AidenBotSessionControllerTest { runCurrent() assertEquals(AidenBotOutgoingStatus.FAILED, inMemory.state.value.outbox.single().status) assertTrue(transport.sends.isEmpty()) - root.deleteRecursively() } @Test fun losingAccessOnTheStreamPurgesTheOutboxEverywhereAndSaysSo() = runTest { - val root = kotlin.io.path.createTempDirectory("bot-outbox").toFile() - val sessions = store(root, { started }) - val transport = FakeTransport(runningSession) - transport.feeds += flow { throw serverError(401, AidenRemoteErrorCode.AUTHENTICATION_REQUIRED) } - val controller = AidenBotSessionController( - runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions, nowMs = { started } - ) - assertTrue(controller.send("Plan lunch")) - assertTrue(root.walkTopDown().any { it.isFile }) - controller.start() - runCurrent() - assertTrue(controller.state.value.outbox.isEmpty()) - assertEquals("That message wasn’t sent. Please try again.", controller.state.value.actionError) - assertTrue("nothing unsent stays on disk", root.walkTopDown().none { it.isFile }) - assertNull(sessions.transcript(chatKey)) - root.deleteRecursively() + 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("That message wasn’t sent. Please try again.", controller.state.value.actionError) + assertTrue("nothing unsent stays on disk after $status", root.walkTopDown().none { it.isFile }) + assertNull(sessions.transcript(chatKey)) + } } @Test fun aBotWhoseAccessChangedAndAnUnpairedMacKeepNothingUnsent() = runTest { - val root = kotlin.io.path.createTempDirectory("bot-outbox").toFile() + 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( @@ -1357,11 +1457,12 @@ class AidenBotSessionControllerTest { "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 }) - root.deleteRecursively() } @Test @@ -1671,4 +1772,83 @@ class AidenBotSessionControllerTest { 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) + 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 }) + } } From 58b7b15d5d74096e0a06e709613f4fd00a538a40 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 15:41:08 -0400 Subject: [PATCH 22/83] Android Bots: Chat activity model and looping previews, saved on pick Picks apply at once and save in the background: taps during a save coalesce onto the returned revision, a failure or conflict rolls back to the saved choice, a connecting Mac holds a pick for 8 s, and each request is bounded to 12 s. Previews replay what each level shows on the Mac, pause when not resumed or off-screen, and hold a level-specific still frame under Reduce Motion. Co-Authored-By: Claude Sonnet 5.5 --- .../bots/AidenBotChatActivityModel.kt | 311 +++++++++++ .../bots/AidenBotChatActivityPreviewCard.kt | 127 +++++ android/app/src/main/res/values/strings.xml | 30 + .../features/bots/AidenBotChatActivityTest.kt | 513 ++++++++++++++++++ 4 files changed, 981 insertions(+) create mode 100644 android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt create mode 100644 android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityPreviewCard.kt create mode 100644 android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt 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..6d8836b9e --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt @@ -0,0 +1,311 @@ +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +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)) +} + +/** + * 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): Boolean = + hasBaseline && server?.supportsBotActivity == true + +// --- 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_chat_activity_preview_running_read, + R.string.bot_chat_activity_preview_running_search, + R.string.bot_chat_activity_preview_running_edit + ) + + /** + * 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 --- + +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, + val saveFailed: Boolean = false +) + +/** A save that outran its limit; the pick returns to the saved choice. */ +class AidenBotChatActivityTimeout : Exception("The save took too long.") + +/** The Mac stayed unreachable for the whole hold. */ +class AidenBotChatActivityUnreachable : Exception("The Mac could not be reached.") + +/** 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 refreshes the Bot, adopts the Mac's revision and choice, tells [onSaved] + * and reports the error once. + * - 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 +) { + 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, saveFailed = false) + 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) } + } + } + + fun dismissError() { + _state.update { it.copy(saveFailed = false) } + } + + private suspend fun saveUntilSettled(first: AidenBotChatActivity) { + var target = first + while (true) { + val detail = try { + holdForConnection() + val revision = _state.value.revision + withTimeoutOrNull(requestLimitMillis) { save(target, revision) } ?: throw AidenBotChatActivityTimeout() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (aidenBotIsRevisionConflict(error)) adoptTheMacsChoice() + _state.update { it.copy(value = it.saved, saveFailed = true) } + return + } + _state.update { it.copy(revision = detail.revision, saved = detail.chatActivity ?: target) } + onSaved(detail) + val following = pending ?: return + pending = null + if (following == _state.value.saved) return + target = following + } + } + + private suspend fun holdForConnection() { + val wait = waitForConnection ?: return + if (withTimeoutOrNull(connectionHoldMillis) { wait() } != true) throw AidenBotChatActivityUnreachable() + } + + /** The Bot changed on the Mac: take its revision and choice, so the next pick can save. */ + private suspend fun adoptTheMacsChoice() { + val refresh = refresh ?: return + val fresh = try { + withTimeoutOrNull(requestLimitMillis) { refresh() } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + null + } ?: return + _state.update { it.copy(revision = fresh.revision, saved = fresh.chatActivity ?: AidenBotChatActivity.DEFAULT) } + onSaved(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 +): 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 + ) +} 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..d40506301 --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityPreviewCard.kt @@ -0,0 +1,127 @@ +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.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) { + 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)) + } + 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/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 0f4378f27..2ea1a30dc 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -641,6 +641,36 @@ 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. + 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 + Reading 1 file… + Searching the web… + Editing 1 file… Queued Sends when the current reply finishes Sending… 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..0b522b7c1 --- /dev/null +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt @@ -0,0 +1,513 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +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.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +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 { + 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 }) + } + + // Leaving the page mid-save returns to the saved choice, and the page can save again. + @Test + fun cancellingTheCallerMidSaveReturnsToTheSavedChoice() = runTest { + val h = Harness() + val job = pick(h.controller, summary) + runCurrent() + job.cancel() + runCurrent() + assertEquals(quiet, h.state.value) + assertFalse(h.state.isSaving) + assertFalse("leaving is not a failure to report", h.state.saveFailed) + } + + // 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) + } + + // 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)) + } + + // 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) + + var finished = -1 + 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)) + assertTrue(frame.steps.size > finished) + finished = frame.steps.size + 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) + ) + } + } + + @Test + fun everyLevelAndWordingHasItsOwnWords() { + assertEquals(3, levels.map { it.label }.toSet().size) + assertEquals(3, levels.map { it.detail }.toSet().size) + val wordings = AidenBotChatActivityWording.entries + assertEquals(2, wordings.map { it.label }.toSet().size) + assertEquals(2, wordings.map { it.detail }.toSet().size) + } +} From 8c527823b41aac6f97812115038d3f222e0db1e3 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 15:49:04 -0400 Subject: [PATCH 23/83] Android Bots: Chat activity reports late saves, refreshes after timeouts, Mac step wording Co-Authored-By: Claude Sonnet 5.5 --- .../bots/AidenBotChatActivityModel.kt | 100 ++++++++--- .../bots/AidenBotChatActivityPreviewCard.kt | 19 ++- android/app/src/main/res/values/strings.xml | 5 +- .../features/bots/AidenBotChatActivityTest.kt | 160 ++++++++++++++++-- 4 files changed, 235 insertions(+), 49 deletions(-) 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 index 6d8836b9e..37e76a9dd 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt @@ -4,10 +4,14 @@ 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 @@ -98,9 +102,9 @@ object AidenBotChatActivityPreview { R.string.bot_chat_activity_preview_step_edit ) private val RUNNING = listOf( - R.string.bot_chat_activity_preview_running_read, - R.string.bot_chat_activity_preview_running_search, - R.string.bot_chat_activity_preview_running_edit + R.string.bot_activity_reading, + R.string.bot_activity_searching, + R.string.bot_activity_editing ) /** @@ -158,6 +162,16 @@ object AidenBotChatActivityPreview { // --- 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, @@ -166,8 +180,11 @@ data class AidenBotChatActivityUiState( /** The revision the next save goes out on. */ val revision: String, val isSaving: Boolean = false, - val saveFailed: 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. */ class AidenBotChatActivityTimeout : Exception("The save took too long.") @@ -175,6 +192,15 @@ class AidenBotChatActivityTimeout : Exception("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 @@ -186,8 +212,12 @@ fun aidenBotIsRevisionConflict(error: Throwable): Boolean = * - 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 refreshes the Bot, adopts the Mac's revision and choice, tells [onSaved] - * and reports the error once. + * - 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 @@ -218,7 +248,7 @@ class AidenBotChatActivityController( suspend fun pick(next: AidenBotChatActivity) { val current = _state.value if (next == current.value) return - _state.value = current.copy(value = next, saveFailed = false) + _state.value = current.copy(value = next, saveFailure = null) if (current.isSaving) { pending = next return @@ -233,25 +263,17 @@ class AidenBotChatActivityController( } fun dismissError() { - _state.update { it.copy(saveFailed = false) } + _state.update { it.copy(saveFailure = null) } } private suspend fun saveUntilSettled(first: AidenBotChatActivity) { var target = first while (true) { - val detail = try { - holdForConnection() - val revision = _state.value.revision - withTimeoutOrNull(requestLimitMillis) { save(target, revision) } ?: throw AidenBotChatActivityTimeout() - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - if (aidenBotIsRevisionConflict(error)) adoptTheMacsChoice() - _state.update { it.copy(value = it.saved, saveFailed = true) } - return - } - _state.update { it.copy(revision = detail.revision, saved = detail.chatActivity ?: target) } - onSaved(detail) + // 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 @@ -259,23 +281,49 @@ class AidenBotChatActivityController( } } + /** 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 fresh = if (refreshes) adoptTheMacsChoice() else null + if (fresh != null && (fresh.chatActivity ?: AidenBotChatActivity.DEFAULT) == target) return true + val failure = when { + aidenBotIsRevisionConflict(error) -> 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 Bot changed on the Mac: take its revision and choice, so the next pick can save. */ - private suspend fun adoptTheMacsChoice() { - val refresh = refresh ?: return + /** 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 + } ?: return null _state.update { it.copy(revision = fresh.revision, saved = fresh.chatActivity ?: AidenBotChatActivity.DEFAULT) } onSaved(fresh) + return fresh } companion object { 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 index d40506301..f0ff1d964 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityPreviewCard.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityPreviewCard.kt @@ -6,6 +6,11 @@ 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 @@ -95,11 +100,15 @@ fun AidenBotChatActivityPreviewCard( .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 - ) + 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)) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2ea1a30dc..02aa4838b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -661,6 +661,8 @@ 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. @@ -668,9 +670,6 @@ Read 1 file Searched the web Edited 1 file - Reading 1 file… - Searching the web… - Editing 1 file… Queued Sends when the current reply finishes Sending… 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 index 0b522b7c1..36443b6f5 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt @@ -316,19 +316,161 @@ class AidenBotChatActivityTest { assertEquals("the same revision", listOf("rev_1", "rev_1"), h.calls.map { it.revision }) } - // Leaving the page mid-save returns to the saved choice, and the page can save again. + // A save the Mac took is reported even if the caller left meanwhile: the screens behind must hear. @Test - fun cancellingTheCallerMidSaveReturnsToTheSavedChoice() = runTest { + fun aSaveConfirmedAfterTheCallerLeavesStillReports() = runTest { val h = Harness() val job = pick(h.controller, summary) runCurrent() job.cancel() runCurrent() - assertEquals(quiet, h.state.value) + 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 aTimeoutWhereTheMacHoldsSomethingElseShowsWhatItHoldsAndSaysOffline() = 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.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 { @@ -414,15 +556,12 @@ class AidenBotChatActivityTest { assertNull("nothing has started yet", thinking.running) assertEquals(emptyList(), thinking.steps) - var finished = -1 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)) - assertTrue(frame.steps.size > finished) - finished = frame.steps.size assertFalse(frame.recapShown) } @@ -501,13 +640,4 @@ class AidenBotChatActivityTest { ) } } - - @Test - fun everyLevelAndWordingHasItsOwnWords() { - assertEquals(3, levels.map { it.label }.toSet().size) - assertEquals(3, levels.map { it.detail }.toSet().size) - val wordings = AidenBotChatActivityWording.entries - assertEquals(2, wordings.map { it.label }.toSet().size) - assertEquals(2, wordings.map { it.detail }.toSet().size) - } } From 375c2f4ca414d36b16465557fca49c783bafe6fa Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 15:54:54 -0400 Subject: [PATCH 24/83] iOS Bots: no silent drop on exhaustion, bounded requests, ordered outbox writes An echoed message out of tries is removed only when its echo names no submission; otherwise it fails with its key, gives the claim back and never settles on that foreign id again. Message POSTs, Stop and the R18 controls are bounded at 12 s and a timeout keeps the key. The store owns one ordered writer per chat, held strongly, ordered with the unpair purge so a late write never recreates a purged file. The outbox has a 10 MB budget sized for 20 worst-case messages and refuses past it; oversized files are kept. Cancelled sends spend no try; release cancels the real sending task; expiry and state-reason failures release echo claims and keep frozen keys; responses apply by stateWrites; Resume follows the Mac's first activity. TestGate is a bounded latch. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotSessionModel.swift | 366 +++++++++++----- .../AidenBotSessionTests.swift | 391 +++++++++++++++--- 2 files changed, 600 insertions(+), 157 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index ef6c5a2b2..1d833a4cb 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -274,11 +274,16 @@ struct AidenBotOutgoingMessage: Equatable, Identifiable, Sendable { 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 + /// An earlier attempt went out and ended without an answer (cut off, timed out + /// or cancelled): the Mac may hold it, whatever a later attempt says. + fileprivate var mayHaveArrived = false fileprivate var submissionId: String? { if case let .sent(id) = status { return id } @@ -290,7 +295,15 @@ struct AidenBotOutgoingMessage: Equatable, Identifiable, Sendable { 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. @@ -375,7 +388,34 @@ actor AidenBotOutboxStore { static let lifetime: TimeInterval = 24 * 60 * 60 /// The most messages one chat may hold waiting; another is refused, visibly. static let maxMessages = 20 - private static let maxBytes = 4_000_000 + /// 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] = [:] + + func put(_ write: Write, for key: AidenBotSessionKey) { lock.withLock { writes[key] = write } } + + func take(_ key: AidenBotSessionKey) -> Write? { lock.withLock { writes.removeValue(forKey: key) } } + + func drop(instanceId: String) { + lock.withLock { writes = writes.filter { $0.key.instanceId != 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 = [] private static let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "sbtbiswas.AidenOnTheGo", category: "BotOutbox") private struct Record: Codable { @@ -436,10 +476,15 @@ actor AidenBotOutboxStore { /// The chat's messages, oldest first. One still unsent after 24 hours comes /// back as not sent; a taken one that old is the Mac's now. func load(_ key: AidenBotSessionKey) -> [AidenBotOutgoingMessage] { + knownDevices[key.instanceId, default: []].insert(key.deviceId) let url = fileURL(key) guard let data = try? Data(contentsOf: url) else { return [] } - guard data.count <= Self.maxBytes, - let envelope = try? JSONDecoder().decode(ReadEnvelope.self, from: data), + guard data.count <= 2 * Self.maxBytes else { + // Never written this large; leave it for a newer build rather than lose it. + Self.log.error("event=outbox_oversized outcome=skipped") + return [] + } + 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 { Self.log.error("event=outbox_unreadable outcome=dropped") @@ -482,7 +527,29 @@ actor AidenBotOutboxStore { return messages } - func save(_ messages: [AidenBotOutgoingMessage], for key: AidenBotSessionKey) { + /// 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 } + 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, @@ -510,11 +577,17 @@ actor AidenBotOutboxStore { } } - func remove(_ key: AidenBotSessionKey) { + 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) { + for device in knownDevices[instanceId] ?? [] { + revokedDevices.insert(Self.pairing(AidenBotSessionKey(instanceId: instanceId, deviceId: device, botID: ""))) + } + pending.drop(instanceId: instanceId) try? fileManager.removeItem(at: root.appending(path: digest(instanceId), directoryHint: .isDirectory)) } @@ -522,6 +595,11 @@ actor AidenBotOutboxStore { 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. + Self.log.error("event=outbox_write outcome=over_budget") + return + } let url = fileURL(key) let protection: [FileAttributeKey: Any] = [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] try fileManager.createDirectory(at: root, withIntermediateDirectories: true, attributes: protection) @@ -580,6 +658,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: Error {} + +/// 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 + + 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 + return false + } + if alreadyCancelled { + continuation.resume(throwing: CancellationError()) + return + } + let work = Task { [weak self] in + do { + let value = try await operation() + self?.finish(.success(value)) + } catch { + self?.finish(.failure(error)) + } + } + let timer = Task { [weak self] in + try? await Task.sleep(for: limit) + guard !Task.isCancelled else { return } + work.cancel() + self?.finish(.failure(AidenBotRequestTimedOut())) + } + lock.withLock { + self.work = work + self.timer = timer + } + } + + 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 { @@ -640,14 +799,12 @@ final class AidenBotSessionModel { @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 isFlushing = false @ObservationIgnored private var backoffTask: Task? @ObservationIgnored private var restoreTask: Task? - @ObservationIgnored private var persistTask: Task? - /// The newest disk write not yet made; only the latest one matters. - @ObservationIgnored private var pendingPersist: PersistOp? @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). @@ -656,7 +813,6 @@ final class AidenBotSessionModel { @ObservationIgnored private var approvalWrites = 0 private enum Intent { case none, requested, acknowledged } - private enum PersistOp { case save([AidenBotOutgoingMessage]), remove } /// Tries a message gets on a connection that keeps failing before it says "Couldn't send". static let maxConnectionAttempts = 3 @@ -673,6 +829,7 @@ final class AidenBotSessionModel { cache: AidenBotSessionCache = .shared, outboxStore: AidenBotOutboxStore? = .shared, retryDelays: [Duration] = [.seconds(1), .seconds(4)], + requestLimit: Duration = .seconds(12), now: @escaping () -> Date = { Date() } ) { self.botID = botID @@ -681,6 +838,7 @@ final class AidenBotSessionModel { 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) ?? [] @@ -694,9 +852,7 @@ final class AidenBotSessionModel { private static func adopted(_ message: AidenBotOutgoingMessage, now: Date) -> AidenBotOutgoingMessage { var message = message message.deferred = false - message.echoed = false - message.echoEntryID = nil - message.echoSubmissionID = nil + message.dropEcho() message.queuedID = nil if message.status == .sending, isExpired(message, now: now) { message.expire() } return message @@ -707,8 +863,9 @@ final class AidenBotSessionModel { } /// The chat closed: sending and retry timers stop at once, nothing more is - /// written, and the unsent messages stay for the next visit. A disk write - /// already queued finishes, so the next launch sees the latest outbox. + /// 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() @@ -793,9 +950,6 @@ final class AidenBotSessionModel { ) } - /// Where the transcript stood; a response is applied only if it didn't move meanwhile. - private var cursor: String { "\(epoch ?? "")\u{1f}\(seq)" } - // MARK: Loading and the event stream /// `GET /session`, after the outbox a previous launch left is restored; @@ -803,7 +957,7 @@ final class AidenBotSessionModel { func load() async { await restoreOutbox() _ = await fetchSnapshot() - await flushOutbox() + await runFlush() } /// `GET /session`. Returns false when no snapshot was loaded. A snapshot @@ -922,7 +1076,8 @@ final class AidenBotSessionModel { if stopIntent == .acknowledged || (next != nil && (stopStartedAt == nil || next?.startedAt != stopStartedAt)) { stopIntent = .none } - if resumeIntent == .acknowledged { resumeIntent = .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: @@ -1040,7 +1195,9 @@ final class AidenBotSessionModel { sent.text.trimmingCharacters(in: .whitespacesAndNewlines) == message.text else { continue } if let theirs = sent.submissionId { // An entry that names its submission belongs to that submission. - if message.submissionId != nil || owned.contains(theirs) { continue } + if message.submissionId != nil || owned.contains(theirs) || message.foreignSubmissionIDs.contains(theirs) { + continue + } } byText = sent } @@ -1050,36 +1207,12 @@ final class AidenBotSessionModel { // 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. + /// 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 } - persist(.save(outbox)) - } - - /// One writer per model; a newer write replaces one not yet made, and a - /// purge runs after any write already under way. - private func persist(_ op: PersistOp) { - guard let cacheKey, let outboxStore else { return } - pendingPersist = op - guard persistTask == nil else { return } - persistTask = Task { [weak self] in - while let next = self?.takePersistOp() { - switch next { - case let .save(messages): await outboxStore.save(messages, for: cacheKey) - case .remove: await outboxStore.remove(cacheKey) - } - } - } - } - - private func takePersistOp() -> PersistOp? { - guard let op = pendingPersist else { - persistTask = nil - return nil - } - pendingPersist = nil - return op + outboxStore?.submit(.save(outbox), for: cacheKey) } /// Adds what a previous launch left on disk, once, before the first snapshot sends it. @@ -1106,12 +1239,23 @@ final class AidenBotSessionModel { scheduleFlush() } - /// Sends the outbox beside whatever called this (a frame, a snapshot, a backoff). + /// Starts the one task that sends the outbox, unless it already runs. private func scheduleFlush() { - guard !released, !isFlushing, nextToDeliver() != nil else { return } + 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? { @@ -1121,26 +1265,27 @@ final class AidenBotSessionModel { return head.key } - /// One sender at a time: a caller that finds one under way leaves the new - /// message to it. + /// 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 { - guard !isFlushing else { return } - isFlushing = true - defer { isFlushing = false } 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].echoed = false + 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 } @@ -1158,7 +1303,7 @@ final class AidenBotSessionModel { outbox[index].baseline = Set(entries.map(\.id)) } let isRetry = outbox[index].retryOf != nil - let mark = cursor + let writes = stateWrites inFlight.insert(.send) if isRetry { inFlight.insert(.retry) } defer { @@ -1166,15 +1311,14 @@ final class AidenBotSessionModel { inFlight.remove(.retry) } do { - let receipt = try await transport.sendBotMessage(botId: botID, request: request, idempotencyKey: key) - if cursor == mark { setStateView(receipt.stateView) } + 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. - if let entry = outbox[current].echoEntryID { claimedEntries.remove(entry) } - outbox[current].echoed = false - outbox[current].echoEntryID = nil - outbox[current].echoSubmissionID = nil + releaseEcho(at: current) } if outbox[current].echoed { outbox.remove(at: current) @@ -1185,20 +1329,28 @@ final class AidenBotSessionModel { } reconcileOutbox(snapshot: false) } catch { - // A closed chat leaves the message as it was for the next visit. - guard !released, let current = outbox.firstIndex(where: { $0.key == key }) else { return } + // 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. + if let current = outbox.firstIndex(where: { $0.key == key }), released || error is CancellationError || Task.isCancelled { + outbox[current].mayHaveArrived = true + } + 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: it never reached the Mac. + // Refused outright. Unless an earlier attempt may have arrived, the + // Mac never took it, and Try again is a new request. outbox[current].status = .failed - outbox[current].keepsKey = false - outbox[current].echoed = false + outbox[current].keepsKey = outbox[current].mayHaveArrived + releaseEcho(at: current) return } outbox[current].keepsKey = true + outbox[current].mayHaveArrived = true outbox[current].connectionAttempts += 1 let attempts = outbox[current].connectionAttempts if attempts < Self.maxConnectionAttempts { @@ -1206,15 +1358,24 @@ final class AidenBotSessionModel { // connected, tries again. Nothing behind it overtakes it. outbox[current].deferred = true scheduleBackoff(after: attempts) - } else if outbox[current].echoed { + } 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)] @@ -1255,7 +1416,7 @@ final class AidenBotSessionModel { outbox = [] if let cacheKey { cache.purge(cacheKey) - persist(.remove) + outboxStore?.submit(.remove, for: cacheKey) } if hadUnsent { errorMessage = "That message wasn’t sent. Please try again." } } @@ -1285,12 +1446,9 @@ final class AidenBotSessionModel { 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 } catch { if questionWrites == mark { question = shown } @@ -1328,12 +1486,9 @@ final class AidenBotSessionModel { 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 } catch { if !aidenBotSessionFailureIsAmbiguous(error) { retainedApproval = nil } @@ -1361,15 +1516,24 @@ final class AidenBotSessionModel { let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) guard canSend, (try? AidenBotMessageRequest(text: text)) != nil, enqueue(text, mode: offersBusyModes ? mode ?? .queue : nil, retryOf: nil) else { return false } - await flushOutbox() + await runFlush() return true } - static let outboxFull = "Too many messages are waiting to send. Try again once they’ve gone." + 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 { - guard outbox.count < AidenBotOutboxStore.maxMessages else { + let bytes = outbox.reduce(Self.storedBytes(text, baseline: entries.count)) { + $0 + Self.storedBytes($1.text, baseline: $1.baseline.count) + } + guard outbox.count < AidenBotOutboxStore.maxMessages, bytes <= AidenBotOutboxStore.maxBytes else { errorMessage = Self.outboxFull return false } @@ -1405,9 +1569,11 @@ final class AidenBotSessionModel { status: .sending, request: outbox[index].request, baseline: outbox[index].baseline, - keepsKey: outbox[index].keepsKey + foreignSubmissionIDs: outbox[index].foreignSubmissionIDs, + keepsKey: outbox[index].keepsKey, + mayHaveArrived: outbox[index].keepsKey && outbox[index].mayHaveArrived ) - await flushOutbox() + await runFlush() return true } @@ -1437,7 +1603,7 @@ final class AidenBotSessionModel { return await retrySend(earlier.id) } guard enqueue(text, mode: offersBusyModes ? .queue : nil, retryOf: turn.id) else { return false } - await flushOutbox() + await runFlush() return true } @@ -1493,14 +1659,14 @@ final class AidenBotSessionModel { let key = retainedKeys[action] ?? UUID() retainedKeys[action] = key let previous = stateView - let mark = cursor let writes = stateWrites if let optimistic { stateView = optimistic } inFlight.insert(action) defer { inFlight.remove(action) } do { - let view = try await request(key) - if cursor == mark { setStateView(view) } + 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 return true } catch { @@ -1547,12 +1713,14 @@ final class AidenBotSessionModel { 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)) @@ -1589,7 +1757,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/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index cf317197b..d338ccd2c 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -1,28 +1,89 @@ import XCTest @testable import AidenOnTheGo -/// Holds a scripted request until the test lets it go. +/// 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 var holding = false - private var waiter: CheckedContinuation? + 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 = [] - var isHolding: Bool { lock.withLock { waiter != nil } } + init(limit: Duration = .seconds(10)) { self.limit = limit } - func hold() { lock.withLock { holding = true } } + var isHolding: Bool { lock.withLock { !waiters.isEmpty } } + var holdingCount: Int { lock.withLock { waiters.count } } + + /// From now on, each request waits for a `release()`. + func hold() { lock.withLock { armed = true } } func pass() async { - guard lock.withLock({ holding }) else { return } - await withCheckedContinuation { continuation in - lock.withLock { waiter = continuation } + 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. func release() { let waiting = lock.withLock { () -> CheckedContinuation? in - holding = false - defer { waiter = nil } - return waiter + guard !order.isEmpty else { + 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() } @@ -47,14 +108,24 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke private var _connectionError: Error? private var _proposalRequests: [(proposalId: String, decision: AidenBotRoutineProposalDecision, key: UUID)] = [] private var _proposalResults: [Result] = [] - private var resumeGate: CheckedContinuation? - var holdResume = false - private var sendGate: CheckedContinuation? + let resumeGate = TestGate() + let sendGate = TestGate() + let stopGate = TestGate() + /// Holds each Resume until `releaseResume()`. + var holdResume: Bool { + get { false } + set { if newValue { resumeGate.hold() } else { resumeGate.releaseAll() } } + } /// Holds each message request until `releaseSend()`. - var holdSend = false - private var stopGate: CheckedContinuation? + var holdSend: Bool { + get { false } + set { if newValue { sendGate.hold() } else { sendGate.releaseAll() } } + } /// Holds each Stop until `releaseStop()`. - var holdStop = false + var holdStop: Bool { + get { false } + set { if newValue { stopGate.hold() } else { stopGate.releaseAll() } } + } private var _stopResults: [Result] = [] private var _stopKeys: [UUID] = [] private var _receiptIDs: [String] = [] @@ -81,12 +152,12 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke func failConnections(with error: Error) { locked { _connectionError = error } } func failNextSend(with error: Error) { locked { _sendErrors.append(error) } } func queueStop(_ result: Result) { locked { _stopResults.append(result) } } - var isHoldingSend: Bool { locked { sendGate != nil } } - var isHoldingResume: Bool { locked { resumeGate != nil } } + 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 { locked { stopGate != nil } } + var isHoldingStop: Bool { stopGate.isHolding } var stopKeys: [UUID] { locked { _stopKeys } } func queueProposalResult(_ result: Result) { locked { _proposalResults.append(result) } @@ -103,29 +174,9 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke 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 releaseSend() { - let gate = locked { () -> CheckedContinuation? in - defer { sendGate = nil } - return sendGate - } - gate?.resume() - } - - func releaseStop() { - let gate = locked { () -> CheckedContinuation? in - defer { stopGate = nil } - return stopGate - } - gate?.resume() - } + func releaseResume() { resumeGate.release() } + func releaseSend() { sendGate.release() } + func releaseStop() { stopGate.release() } func botSession(botId: String) async throws -> AidenBotSession { let next = locked { () -> Result? in @@ -166,11 +217,7 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke _sendModes.append(request.whenBusy) return _sendErrors.isEmpty ? nil : _sendErrors.removeFirst() } - if holdSend { - await withCheckedContinuation { continuation in - locked { sendGate = continuation } - } - } + await sendGate.pass() if let error { throw error } let id = locked { _receiptIDs.isEmpty ? "17" : _receiptIDs.removeFirst() } return try AidenRemoteJSONDecoder.decode( @@ -181,11 +228,7 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke 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() } @@ -198,11 +241,7 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke func stopBotSession(botId: String, idempotencyKey: UUID) async throws -> AidenBotSessionStateView { locked { _stopKeys.append(idempotencyKey) } - if holdStop { - await withCheckedContinuation { continuation in - locked { stopGate = continuation } - } - } + await stopGate.pass() let next = locked { _stopResults.isEmpty ? nil : _stopResults.removeFirst() } return try (next ?? .success(try AidenBotSessionStateView(state: .idle))).get() } @@ -503,7 +542,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.") @@ -1948,6 +1991,7 @@ final class AidenBotSessionOptimisticTests: XCTestCase { cache: AidenBotSessionCache? = nil, store: AidenBotOutboxStore? = nil, retryDelays: [Duration] = [.seconds(60)], + requestLimit: Duration = .seconds(12), now: @escaping () -> Date = { Date() } ) -> AidenBotSessionModel { let model = AidenBotSessionModel( @@ -1957,6 +2001,7 @@ final class AidenBotSessionOptimisticTests: XCTestCase { cache: cache ?? AidenBotSessionCache(), outboxStore: store, retryDelays: retryDelays, + requestLimit: requestLimit, now: now ) model.activitySupported = supported @@ -2768,6 +2813,234 @@ final class AidenBotSessionOptimisticTests: XCTestCase { 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.. Date: Wed, 14 Oct 2026 15:56:30 -0400 Subject: [PATCH 25/83] =?UTF-8?q?Android=20Bots:=20Advanced=20=E2=86=92=20?= =?UTF-8?q?Chat=20activity=20page,=20row,=20and=20callers=20(Session,=20Pr?= =?UTF-8?q?ofile,=20Home)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../features/bots/AidenBotAdvancedScreen.kt | 98 +++++- .../bots/AidenBotChatActivityScreen.kt | 294 ++++++++++++++++++ .../features/bots/AidenBotProfileScreen.kt | 5 +- .../features/bots/AidenBotSessionScreen.kt | 13 +- .../features/bots/AidenBotsHomeScreen.kt | 14 +- .../features/remote/AidenRemoteCoordinator.kt | 8 + .../features/bots/AidenBotChatActivityTest.kt | 89 ++++++ 7 files changed, 504 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt 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..26bc141f6 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 @@ -191,11 +195,30 @@ 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 = {} ) { 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) } @@ -219,14 +242,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) } }, @@ -243,7 +266,10 @@ fun AidenBotAdvancedSheet( 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 +288,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 +299,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,10 +339,63 @@ 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) { + AidenBotAdvancedSection(title = stringResource(R.string.bot_chat_activity_title)) { + AidenConnectedColumn { + AidenGroupCard( + index = 0, + count = 1, + onClick = { + // A controller still saving keeps its own revision: reopening must not fork a second writer. + if (chatActivityController?.state?.value?.isSaving != true) { + 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 = palette.danger, style = MaterialTheme.typography.bodySmall) } + } + } + } + + val controller = chatActivityController + if (showsChatActivityRow && showingChatActivity && controller != null && chatActivityHost != null) { + AidenBotChatActivityDialog( + botName = latestBot.name, + controller = controller, + launch = chatActivityHost.launch, + onDismiss = { showingChatActivity = false } + ) + } +} + +/** Model and Access placeholders while the capability catalog arrives. */ +@Composable +private fun AidenBotAdvancedPlaceholder(loadingDescription: String) { + Column( + verticalArrangement = Arrangement.spacedBy(AidenUi.SectionGap), + modifier = Modifier.clearAndSetSemantics { contentDescription = loadingDescription } + ) { + repeat(2) { section -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + AidenSkeletonBlock(width = 96.dp, height = 12.dp) + AidenSkeletonBlock(height = if (section == 0) 96.dp else 72.dp, shape = MaterialTheme.shapes.large) } } } 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..cfcdacccb --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt @@ -0,0 +1,294 @@ +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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 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. + * + * 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. A pick that finishes after the + * person has gone is written to the Bot cache through [cache], not to a view that is gone. + */ +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 +) { + fun controller(bot: AidenBotDetail, onSaved: (AidenBotDetail) -> Unit): AidenBotChatActivityController = + aidenBotChatActivityController( + bot, + client = client, + onSaved = { saved -> + cache(saved) + onSaved(saved) + }, + waitForConnection = waitForConnection + ) +} + +/** + * 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. */ +fun AidenRemoteCoordinator.botChatActivityHost(): AidenBotChatActivityHost { + val instance = activeInstanceId + fun samePairing() = activeInstanceId == instance + return AidenBotChatActivityHost( + client = { if (samePairing()) client.value else null }, + waitForConnection = { samePairing() && aidenBotAwaitConnected(connectionState, client) && samePairing() }, + launch = { block -> launchDetached(block) }, + cache = { detail -> if (samePairing()) botCache.putBotDetail(detail) } + ) +} + +/** + * 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 = palette.danger, + 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)) + 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() + } + } +} 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..2676f2aba 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 @@ -487,7 +487,10 @@ fun AidenBotProfileScreen( onSaved = { accept(it) showAdvanced = false - } + }, + chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true), + chatActivityHost = remember(client) { coordinator.botChatActivityHost() }, + onChatActivitySaved = { accept(it) } ) } 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 3f473405e..ac5e63e2d 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 @@ -191,7 +191,10 @@ fun AidenBotSessionScreen( } catch (e: CancellationException) { throw e } catch (_: Exception) { - 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) advancedBot = cached + else coordinator.presentError(resources.getString(R.string.bot_session_unreachable)) } finally { openingAdvanced = false } @@ -268,6 +271,7 @@ fun AidenBotSessionScreen( } advancedBot?.let { bot -> + val chatActivityHost = remember(cl) { coordinator.botChatActivityHost() } AidenBotAdvancedSheet( bot = bot, client = cl, @@ -276,6 +280,13 @@ fun AidenBotSessionScreen( coordinator.botCache.putBotDetail(saved) advancedBot = null scope.launch { controller.refetch() } + }, + chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true), + chatActivityHost = chatActivityHost, + onChatActivitySaved = { saved -> + coordinator.botCache.putBotDetail(saved) + controller.setWording(saved.chatActivity?.wording ?: AidenBotChatActivityWording.FOLLOWS) + scope.launch { controller.refetch() } } ) } 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 c5595adb2..f35bbe1b4 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 @@ -262,7 +262,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 @@ -440,7 +447,10 @@ fun AidenBotsHomeScreen( advancedBot = null advancedClient = null scope.launch { viewModel.loadBots(force = true) } - } + }, + chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true), + chatActivityHost = remember(advancedClient) { coordinator.botChatActivityHost() }, + onChatActivitySaved = { saved -> coordinator.botCache.putBotDetail(saved) } ) } 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 e6d821e10..2d632397d 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 @@ -143,6 +143,14 @@ class AidenRemoteCoordinator( val activeInstanceId: String? get() = installationStore.activeInstallation?.instanceId + /** + * 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) { + scope.launch { block() } + } + /** * 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 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 index 36443b6f5..6fb8de72f 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt @@ -3,7 +3,9 @@ package sbtbiswas.AidenOnTheGo.features.bots import kotlinx.coroutines.CompletableDeferred 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.TestScope @@ -21,6 +23,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import sbtbiswas.AidenOnTheGo.features.remote.AidenConnectionState import sbtbiswas.AidenOnTheGo.models.* import sbtbiswas.AidenOnTheGo.networking.AidenRemoteClient import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteCapability @@ -509,6 +512,92 @@ class AidenBotChatActivityTest { 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() + } + } + // Advanced offers the page only to a phone that can use it. @Test fun theRowNeedsTheMacsFeatureTheGrantAndABot() { From cb40568c0267cf731b0487fe449f2a6dbf352e8b Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 16:01:54 -0400 Subject: [PATCH 26/83] Docs: Bot chat activity slice D rulings, revision 30 contract, plan index and memory Co-Authored-By: Claude Sonnet 5.5 --- .memory/android-localization.md | 2 +- .memory/bot-chat-activity.md | 44 ++++++++++++- .memory/bots-rework-ui.md | 1 + docs/aiden-remote-api-v1.md | 28 +++++++++ docs/plans/README.md | 2 +- .../2026-10-14-bot-chat-activity-design.md | 63 ++++++++++++++++++- 6 files changed, 136 insertions(+), 4 deletions(-) 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..4c312efd3 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,45 @@ 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–R21); 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. +- `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. + +### 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 (shipped on Android, iOS is open). +- 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`, `aiden-remote-state.test.ts`, `aiden-remote-pairing.test.ts`, `aiden-remote-bots.test.ts`, `aiden-remote-bot-session.test.ts`, `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. + +### Parked and final-wave (not done; full list in spec §17) +- R15 (slice A phone surfaces: instant apply, skeletons) and R21 (cache last-known server capabilities per installation, so the line and Steer/Queue work before `serverInfo` arrives; cold offline start loses the Chat activity row until then); the iOS still badge (R19). +- iOS chat polish (44 pt hit areas, semantic dimming token, glance 30 fps cap, `@ScaledMetric`, `start()` not blocking on `loadSummary`, announcer reset, endless-skeleton failure state); iOS Chat activity (late-save reporting through the cache, offline copy, off-screen pause check inside a `Form`, Mac phase words for running labels). +- Android chat polish and controller hardening (scroll-follow, contrast tokens, one power-save receiver, lock scope, restore-on-throw, caps after merge), and the 11b review round (unpinned cache writes, untested pin and `launchDetached`, jobs not cancelled on unpair). +- Desktop/protocol test gaps (reconnect snapshot mid-run, 40-code-point app cut, identical phase emits nothing, partial-wiring gate, memo-prune, `setTimeout(50)` negative assertion), a shared fail-closed classifier for desktop and Remote, `placed()` failure reporting once per streak, null-literal fixture samples. +- 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/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index cd7160cc5..3c48827e0 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,30 @@ 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. 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. +- 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`. + +**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, restore, archive, avatar PUT and DELETE) strips the field for other devices. +- `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). 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/specs/2026-10-14-bot-chat-activity-design.md b/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md index cdfc70751..a6df8073b 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 (GET, PATCH, restore, archive, avatar PUT and DELETE) 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,35 @@ 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 (throttled retry through the projector's serial lane). Routine and intro inputs never reach a phone. The projector keeps only personal ids (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". + - **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, not shipped): cache the last known server capabilities per installation so the line, Steer/Queue and `whenBusy` work before `serverInfo` arrives. + - **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 PUT/DELETE leaking it); `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. + - **Parked and final-wave items (not done in D as of this note).** They are follow-ups, not shipped: + - Desktop and protocol: a shared fail-closed classifier used by desktop and Remote; storing `resumedFrom` baseline serialized once; tests for a reconnect snapshot mid-run carrying activity and queued, `projectBotSessionActivity` (app phase and 40-code-point cut), an identical phase emitting no frame, and the session/openEvents gate under partial wiring; an observable memo-prune test for `submissionId`; reporting once per failing `placed()` streak; replacing the fixed `setTimeout(50)` negative assertion; overlapping Stops where one fails briefly clear "Stopping" (cosmetic). + - Fixtures: null-literal rejected samples (`app: null`, `queued: null`, `label: null` with `submissionId`) and an Android raw-null guard; a whitespace-only `app` label parity note. + - iOS chat polish: Try again and remove hit areas of at least 44 pt; the Steer/Queue menu reachable with an empty draft; Queued/Sending dimming through a semantic token instead of 0.55 opacity (contrast of at least 4.5:1); glance `TimelineView` minimum interval 1/30; reset the announcer when the line hides; a `start()` failure before the model exists should show a hint and Try again, not an endless skeleton; stop the shimmer when unreachable; `@ScaledMetric` for the status mark and composer row; `start()` must not block `listen()` or the outbox on `loadSummary`; drop the dead `.id("status")`; pure helper tests for the busy-mode default and reset; bound the Chat activity test polls; R19 still badge for canonical faces on iOS. + - iOS Chat activity: verify the off-screen preview pause inside a `Form` manually (fall back to `onAppear`/`onDisappear`); route a late save's `onSaved` through the cache/coordinator so it reaches the screens behind a dismissed view; copy for the offline case distinct from "Couldn't Save"; running labels use the Mac's phase words; refresh after a Timeout; confirmed-after-leave still calls `onSaved`. + - 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. + - Open review rounds when this was written: Android Chat activity callers re-write the Bot cache unpinned (pin bypass, double write), the pin and `launchDetached` lack tests, detached jobs are not cancelled on unpair, and a cold offline start loses the Chat activity row until R21 (persisted capabilities) lands; iOS fix round 3 (outbox exhaustion, bounded requests, ordered writes) was awaiting re-review. + - R15 and R21 above, and 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). From 33dd24924f4ffec8b1ca977b483bf4da2b3b7f39 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 16:07:16 -0400 Subject: [PATCH 27/83] Android Bots: Chat activity cache stays pinned to its pairing; cold offline row Co-Authored-By: Claude Sonnet 5.5 --- .../features/bots/AidenBotActivity.kt | 4 +- .../features/bots/AidenBotAdvancedScreen.kt | 51 +++--- .../bots/AidenBotChatActivityModel.kt | 19 ++- .../bots/AidenBotChatActivityScreen.kt | 38 +++-- .../features/bots/AidenBotProfileScreen.kt | 5 +- .../features/bots/AidenBotSessionScreen.kt | 11 +- .../features/bots/AidenBotsHomeScreen.kt | 5 +- .../features/remote/AidenRemoteCoordinator.kt | 34 +++- .../features/bots/AidenBotChatActivityTest.kt | 160 +++++++++++++++++- 9 files changed, 274 insertions(+), 53 deletions(-) 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 index a706f4455..cf01db09f 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt @@ -145,8 +145,8 @@ fun aidenBotFaceGlance(timeMs: Long): AidenBotFaceGlance { * `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?): AidenBotChatActivity? = - if (server?.supportsBotActivity == true) detail?.chatActivity else null +fun aidenBotChatActivity(server: AidenServer?, detail: AidenBotDetail?, cachedGrant: Boolean = false): AidenBotChatActivity? = + if (aidenBotActivitySupported(server, cachedGrant)) detail?.chatActivity else null // --- The chat's presentation (R14, R19): pure, so the JVM suite can hold it --- 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 26bc141f6..81cbd894d 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 @@ -345,26 +345,21 @@ fun AidenBotAdvancedSheet( // 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) { - AidenBotAdvancedSection(title = stringResource(R.string.bot_chat_activity_title)) { - AidenConnectedColumn { - AidenGroupCard( - index = 0, - count = 1, - onClick = { - // A controller still saving keeps its own revision: reopening must not fork a second writer. - if (chatActivityController?.state?.value?.isSaving != true) { - chatActivityController = chatActivityHost?.controller(latestBot) { saved -> - latestBot = saved - reporter.saved(saved) - } - } - showingChatActivity = true + AidenConnectedColumn { + AidenGroupCard( + index = 0, + count = 1, + onClick = { + chatActivityController = chatActivityHost?.controller(latestBot) { saved -> + latestBot = saved + reporter.saved(saved) } - ) { - 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) + 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) } } } @@ -385,18 +380,26 @@ fun AidenBotAdvancedSheet( } } -/** Model and Access placeholders while the capability catalog arrives. */ +/** + * 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 } ) { - repeat(2) { section -> - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - AidenSkeletonBlock(width = 96.dp, height = 12.dp) - AidenSkeletonBlock(height = if (section == 0) 96.dp else 72.dp, shape = MaterialTheme.shapes.large) - } + 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) } } } 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 index 37e76a9dd..37ab7d949 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt @@ -64,12 +64,21 @@ fun aidenBotChatActivitySummary(value: AidenBotChatActivity?): String { 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), the grant negotiated and persisted + * for the pairing stands in: it is only ever requested from a Mac that announced the feature. + * Once `/server` answers it decides. + */ +fun aidenBotActivitySupported(server: AidenServer?, cachedGrant: Boolean): Boolean = + server?.supportsBotActivity ?: cachedGrant + /** * 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): Boolean = - hasBaseline && server?.supportsBotActivity == true +fun aidenBotShowsChatActivity(server: AidenServer?, hasBaseline: Boolean, cachedGrant: Boolean = false): Boolean = + hasBaseline && aidenBotActivitySupported(server, cachedGrant) // --- The previews' script: what each level shows on the Mac --- @@ -291,10 +300,14 @@ class AidenBotChatActivityController( throw error } catch (error: Exception) { val refreshes = aidenBotIsRevisionConflict(error) || aidenBotChatActivityTransportFailure(error) + val before = _state.value.saved val fresh = if (refreshes) adoptTheMacsChoice() else null - if (fresh != null && (fresh.chatActivity ?: AidenBotChatActivity.DEFAULT) == target) return true + 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 } 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 index cfcdacccb..cd3743875 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt @@ -4,10 +4,8 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.verticalScroll @@ -60,11 +58,13 @@ import sbtbiswas.AidenOnTheGo.ui.theme.aidenReadableWidth // `AidenBotChatActivityModel.kt`; the looping preview in `AidenBotChatActivityPreviewCard.kt`. /** - * What Chat activity needs from its surroundings, bound to one pairing. + * 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. A pick that finishes after the - * person has gone is written to the Bot cache through [cache], not to a view that is gone. + * 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. */ @@ -74,16 +74,33 @@ class AidenBotChatActivityHost( val launch: (suspend () -> Unit) -> Unit, val cache: (AidenBotDetail) -> Unit ) { - fun controller(bot: AidenBotDetail, onSaved: (AidenBotDetail) -> Unit): AidenBotChatActivityController = - aidenBotChatActivityController( + private class Live(val controller: AidenBotChatActivityController, var listener: (AidenBotDetail) -> Unit) + + private val live = mutableMapOf() + + /** + * 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 + return it.controller + } + lateinit var entry: Live + val controller = aidenBotChatActivityController( bot, client = client, onSaved = { saved -> cache(saved) - onSaved(saved) + entry.listener(saved) }, waitForConnection = waitForConnection ) + entry = Live(controller, onSaved) + live[bot.id] = entry + return controller + } } /** @@ -97,10 +114,10 @@ suspend fun aidenBotAwaitConnected(connection: Flow launchDetached(block) }, @@ -279,7 +296,6 @@ private fun AidenBotChatActivityChoice( 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) 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 2676f2aba..25bda780d 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 @@ -488,9 +488,10 @@ fun AidenBotProfileScreen( accept(it) showAdvanced = false }, - chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true), + chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true, cachedGrant = coordinator.cachedBotActivityGrant), chatActivityHost = remember(client) { coordinator.botChatActivityHost() }, - onChatActivitySaved = { accept(it) } + // 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/AidenBotSessionScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt index ac5e63e2d..b943ce35b 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 @@ -129,6 +129,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.cachedBotActivityGrant) if (cl == null) { Box(Modifier.fillMaxSize().background(palette.canvas), contentAlignment = Alignment.Center) { Text(stringResource(R.string.bot_session_connect), color = palette.secondary) @@ -143,7 +144,7 @@ fun AidenBotSessionScreen( AidenRemoteBotSessionTransport(cl), scope, knownState, - activitySupported = serverInfo?.supportsBotActivity == true, + activitySupported = activitySupportedNow, sessionKey = sessionKey, store = coordinator.botSessionStore ) @@ -153,12 +154,12 @@ fun AidenBotSessionScreen( onDispose { controller.stopFollowing() } } val ui by controller.state.collectAsStateWithLifecycle() - val activitySupported = serverInfo?.supportsBotActivity == true + 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. val botDetails by coordinator.botCache.botDetails.collectAsStateWithLifecycle() - val chatActivity = aidenBotChatActivity(serverInfo, botDetails[botId]) + val chatActivity = aidenBotChatActivity(serverInfo, botDetails[botId], coordinator.cachedBotActivityGrant) LaunchedEffect(controller, chatActivity) { controller.setWording(chatActivity?.wording ?: AidenBotChatActivityWording.FOLLOWS) } @@ -281,10 +282,10 @@ fun AidenBotSessionScreen( advancedBot = null scope.launch { controller.refetch() } }, - chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true), + chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true, cachedGrant = coordinator.cachedBotActivityGrant), chatActivityHost = chatActivityHost, + // The host has already cached the pick (pinned to its pairing); this only refreshes the chat. onChatActivitySaved = { saved -> - coordinator.botCache.putBotDetail(saved) controller.setWording(saved.chatActivity?.wording ?: AidenBotChatActivityWording.FOLLOWS) scope.launch { controller.refetch() } } 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 f35bbe1b4..82f1f892b 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 @@ -448,9 +448,8 @@ fun AidenBotsHomeScreen( advancedClient = null scope.launch { viewModel.loadBots(force = true) } }, - chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true), - chatActivityHost = remember(advancedClient) { coordinator.botChatActivityHost() }, - onChatActivitySaved = { saved -> coordinator.botCache.putBotDetail(saved) } + chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true, cachedGrant = coordinator.cachedBotActivityGrant), + chatActivityHost = remember(advancedClient) { coordinator.botChatActivityHost() } ) } 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 2d632397d..5223aaac1 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 @@ -147,10 +147,37 @@ class AidenRemoteCoordinator( * 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) { - scope.launch { block() } + fun launchDetached(block: suspend () -> Unit): Job { + val entry = DetachedWork(activeInstanceId) + val job = scope.launch { block() } + entry.job = job + detachedWork += entry + job.invokeOnCompletion { detachedWork.remove(entry) } + return job } + private class DetachedWork(val instanceId: String?) { + 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: (String?) -> Boolean) { + detachedWork.filterNot { keep(it.instanceId) }.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(activeInstanceId to key) { create() } as T + + /** What Chat activity persisted for the active pairing; stands in until `/server` answers. */ + val cachedBotActivityGrant: Boolean + get() = installationStore.activeInstallation?.hasNegotiatedAccess(AidenRemoteCapability.BOT_ACTIVITY) == 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 @@ -193,6 +220,7 @@ class AidenRemoteCoordinator( activationGeneration += 1 val generation = activationGeneration val installation = installationStore.activeInstallation + cancelDetachedWork { it == installation?.instanceId } if (installation == null) { _client.value = null _serverInfo.value = null @@ -358,6 +386,8 @@ class AidenRemoteCoordinator( .orEmpty() ) } + cancelDetachedWork { it != installation.instanceId } + pairingMemos.keys.removeAll { it.first == installation.instanceId } archiveStore.purge(installation.instanceId) workspaceCache.purge(installation.instanceId, knownWorkspaceIds) scheduledCache.purge(installation.instanceId) 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 index 6fb8de72f..a282e6443 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt @@ -1,6 +1,9 @@ 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 @@ -8,6 +11,7 @@ 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 @@ -20,10 +24,18 @@ 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 @@ -35,6 +47,9 @@ 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")) @@ -372,7 +387,7 @@ class AidenBotChatActivityTest { } @Test - fun aTimeoutWhereTheMacHoldsSomethingElseShowsWhatItHoldsAndSaysOffline() = runTest { + fun aTimeoutWhereTheMacHoldsSomethingElseShowsWhatItHoldsAndSaysItChanged() = runTest { val h = Harness(refresh = { detail("rev_5", summaryThinking) }) pick(h.controller, detailed) runCurrent() @@ -380,6 +395,17 @@ class AidenBotChatActivityTest { 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) } @@ -598,6 +624,122 @@ class AidenBotChatActivityTest { } } + // --- The pairing pin (coordinator level) --- + + private fun exchange(instance: String, capabilities: List = listOf(AidenRemoteCapability.CHAT_READ)) = + AidenPairingExchange( + instanceId = instance, + deviceId = "device-$instance", + 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) + } + + @Test + fun theCachedGrantIsTheNegotiatedOneForThePairingInUse() { + val installations = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) + val coordinator = coordinatorFor(installations) + installations.addInstallation( + exchange("one", listOf(AidenRemoteCapability.CHAT_READ, AidenRemoteCapability.BOT_READ, AidenRemoteCapability.BOT_ACTIVITY)), + null + ) + assertTrue(coordinator.cachedBotActivityGrant) + installations.addInstallation(exchange("two"), null) + assertFalse("the active pairing never held it", coordinator.cachedBotActivityGrant) + } + + // 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() { @@ -612,6 +754,22 @@ class AidenBotChatActivityTest { 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, cachedGrant = true)) + assertFalse("never granted", aidenBotShowsChatActivity(null, hasBaseline = true, cachedGrant = false)) + assertFalse("still needs a Bot on screen", aidenBotShowsChatActivity(null, hasBaseline = false, cachedGrant = true)) + assertFalse( + "once the Mac answers it decides, whatever was cached", + aidenBotShowsChatActivity(mac(emptyList(), listOf(AidenRemoteCapability.CHAT_READ)), hasBaseline = true, cachedGrant = true) + ) + assertTrue(aidenBotActivitySupported(null, cachedGrant = true)) + assertFalse(aidenBotActivitySupported(null, cachedGrant = false)) + } + // Previews: what each level shows on the Mac. private val levels = AidenBotChatActivityLevel.entries private val all = AidenBotChatActivityPreview.FRAME_MILLIS.indices From 662e34a728acec5b3df2ad52b6c47cc43ef8f69d Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 17:14:48 -0400 Subject: [PATCH 28/83] iOS Bots: failed messages never settle on a tagged entry; key reuse survives relaunch Co-Authored-By: Claude Opus 5.5 --- .../Bots/AidenBotSessionChatView.swift | 14 +- .../Features/Bots/AidenBotSessionModel.swift | 176 +++++++---- .../AidenBotSessionTests.swift | 288 +++++++++++++++++- 3 files changed, 413 insertions(+), 65 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift index ccafb9ebe..a2d4bf6c5 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift @@ -113,13 +113,13 @@ struct AidenBotSessionChatView: View { .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) { await start() @@ -144,9 +144,17 @@ struct AidenBotSessionChatView: View { } } + /// 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 ?? model?.restoreNotice ?? (model?.hasLoaded == true ? model?.errorMessage : nil) + } + private func clearErrors() { deleteErrorMessage = nil - model?.errorMessage = 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? { diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index 1d833a4cb..537801c9f 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -105,6 +105,8 @@ enum AidenBotSessionCopy { 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." @@ -357,9 +359,15 @@ final class AidenBotSessionCache { } /// Stores the outbox when `owner` writes this chat; false when another model does. + /// `mustOwn` also refuses a chat nobody holds any more (after an unpair). @discardableResult - func storeOutbox(_ outbox: [AidenBotOutgoingMessage], for key: AidenBotSessionKey, owner: ObjectIdentifier) -> Bool { - guard owners[key] == nil || owners[key] == owner else { return false } + func storeOutbox( + _ outbox: [AidenBotOutgoingMessage], + for key: AidenBotSessionKey, + owner: ObjectIdentifier, + mustOwn: Bool = false + ) -> Bool { + guard owners[key] == owner || (!mustOwn && owners[key] == nil) else { return false } owners[key] = owner outboxes[key] = outbox.isEmpty ? nil : outbox return true @@ -402,13 +410,24 @@ actor AidenBotOutboxStore { 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 } } + func put(_ write: Write, for key: AidenBotSessionKey) { + lock.withLock { + writes[key] = write + pairings[key.instanceId, default: []].insert(key.deviceId) + } + } func take(_ key: AidenBotSessionKey) -> Write? { lock.withLock { writes.removeValue(forKey: key) } } - func drop(instanceId: String) { - lock.withLock { writes = writes.filter { $0.key.instanceId != instanceId } } + /// 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) ?? [] + } } } @@ -431,6 +450,9 @@ actor AidenBotOutboxStore { let baseline: [String] let connectionAttempts: Int let keepsKey: Bool + /// Absent in records written before fix round 4. + let mayHaveArrived: Bool? + let foreignSubmissionIDs: [String]? } /// One record decoded on its own, so a bad one costs only itself. @@ -475,25 +497,34 @@ actor AidenBotOutboxStore { /// The chat's messages, oldest first. One still unsent after 24 hours comes /// back as not sent; a taken one that old is the Mac's now. - func load(_ key: AidenBotSessionKey) -> [AidenBotOutgoingMessage] { + func load(_ key: AidenBotSessionKey) -> [AidenBotOutgoingMessage] { restore(key).messages } + + /// The chat's messages, and whether some couldn't be read back. 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) -> (messages: [AidenBotOutgoingMessage], unreadable: Bool) { knownDevices[key.instanceId, default: []].insert(key.deviceId) let url = fileURL(key) - guard let data = try? Data(contentsOf: url) else { return [] } + guard let data = try? Data(contentsOf: url) else { return ([], false) } guard data.count <= 2 * Self.maxBytes else { - // Never written this large; leave it for a newer build rather than lose it. - Self.log.error("event=outbox_oversized outcome=skipped") - return [] + // Never written this large. + Self.log.error("event=outbox_oversized outcome=set_aside") + setAside(url, 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 { - Self.log.error("event=outbox_unreadable outcome=dropped") - try? fileManager.removeItem(at: url) - return [] + Self.log.error("event=outbox_unreadable outcome=set_aside") + setAside(url, keepingOriginal: false) + return ([], true) } let readable = envelope.messages.compactMap(\.record) - if readable.count != envelope.messages.count { - Self.log.error("event=outbox_record_unreadable outcome=dropped count=\(envelope.messages.count - readable.count, privacy: .public)") + let lostSome = readable.count != envelope.messages.count + if lostSome { + // The readable ones are written back; the file as it was is kept beside them. + Self.log.error("event=outbox_record_unreadable outcome=set_aside count=\(envelope.messages.count - readable.count, privacy: .public)") + setAside(url, keepingOriginal: true) } let cutoff = now().addingTimeInterval(-Self.lifetime) var changed = readable.count != envelope.messages.count @@ -517,6 +548,8 @@ actor AidenBotOutboxStore { message.baseline = Set(record.baseline) message.connectionAttempts = record.connectionAttempts message.keepsKey = record.keepsKey + message.mayHaveArrived = record.mayHaveArrived ?? false + message.foreignSubmissionIDs = Set(record.foreignSubmissionIDs ?? []) if expired, message.status == .sending { message.expire() changed = true @@ -524,7 +557,21 @@ actor AidenBotOutboxStore { messages.append(message) } if changed { save(messages, for: key) } - return messages + return (messages, lostSome) + } + + /// Moves (or copies) a file this build can't fully read out of the chat's way. + private func setAside(_ url: URL, 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 { + Self.log.error("event=outbox_set_aside outcome=failed") + } } /// Hands a write to the store's own writer, which makes it in order with @@ -563,7 +610,10 @@ actor AidenBotOutboxStore { request: message.request, baseline: Array(message.baseline), connectionAttempts: message.connectionAttempts, - keepsKey: message.keepsKey + keepsKey: message.keepsKey, + mayHaveArrived: message.mayHaveArrived, + // The entry it waited on goes with it too, as adopting it in memory does. + foreignSubmissionIDs: message.foreignSubmissionIDs.union(message.echoSubmissionID.map { [$0] } ?? []).sorted() ) } if records.isEmpty { @@ -584,10 +634,11 @@ actor AidenBotOutboxStore { /// This Mac was unpaired: its files go, writes still waiting are dropped, /// and its pairings are never written again. func purge(instanceId: String) { - for device in knownDevices[instanceId] ?? [] { + // 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: ""))) } - pending.drop(instanceId: instanceId) try? fileManager.removeItem(at: root.appending(path: digest(instanceId), directoryHint: .isDirectory)) } @@ -660,7 +711,9 @@ func aidenBotSessionFailureIsAmbiguous(_ error: Error) -> Bool { /// 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: Error {} +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 @@ -688,34 +741,32 @@ private final class AidenBotRequestRace: @unchecked Sendable { 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 - return false - } - if alreadyCancelled { - continuation.resume(throwing: CancellationError()) - return - } - let work = Task { [weak self] in - do { - let value = try await operation() - self?.finish(.success(value)) - } catch { - self?.finish(.failure(error)) + 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)) + } } - } - let timer = Task { [weak self] in - try? await Task.sleep(for: limit) - guard !Task.isCancelled else { return } - work.cancel() - self?.finish(.failure(AidenBotRequestTimedOut())) - } - lock.withLock { self.work = work - self.timer = timer + 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() { @@ -782,6 +833,8 @@ final class AidenBotSessionModel { /// 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 let cache: AidenBotSessionCache @@ -851,6 +904,9 @@ final class AidenBotSessionModel { /// 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 + // Frozen and still unsettled: an attempt went out with no answer this + // visit saw (cut off, timed out, or still on its way), so the Mac may hold it. + if message.status == .sending, message.request != nil { message.mayHaveArrived = true } message.deferred = false message.dropEcho() message.queuedID = nil @@ -862,10 +918,11 @@ final class AidenBotSessionModel { 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. + /// The chat closed: sending and retry timers stop at once, and the unsent + /// messages stay for the next visit. What the cut-off request leaves behind + /// (it may have arrived) is still written, unless a newer visit holds the + /// chat. 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() @@ -964,7 +1021,9 @@ final class AidenBotSessionModel { /// means the Mac is reachable, so the outbox goes out, without waiting here. private func fetchSnapshot() async -> Bool { do { - let session = 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) @@ -1194,8 +1253,11 @@ final class AidenBotSessionModel { 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. - if message.submissionId != nil || owned.contains(theirs) || message.foreignSubmissionIDs.contains(theirs) { + // 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 } } @@ -1207,11 +1269,13 @@ final class AidenBotSessionModel { // 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. + /// an older model left open never overwrites a newer one's messages. Once + /// released it still records what its cut-off request left (it may have + /// arrived), but only while it still holds the chat. 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 } + guard let cacheKey, + cache.storeOutbox(outbox, for: cacheKey, owner: ObjectIdentifier(self), mustOwn: released) else { return } outboxStore?.submit(.save(outbox), for: cacheKey) } @@ -1220,8 +1284,10 @@ final class AidenBotSessionModel { guard let cacheKey, let outboxStore else { return } if restoreTask == nil { restoreTask = Task { [weak self] in - let stored = await outboxStore.load(cacheKey) - guard let self, !stored.isEmpty else { return } + let (stored, unreadable) = await outboxStore.restore(cacheKey) + guard let self else { return } + if unreadable { self.restoreNotice = AidenBotSessionCopy.outboxUnrestorable } + guard !stored.isEmpty else { return } 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) } @@ -1345,7 +1411,7 @@ final class AidenBotSessionModel { // Refused outright. Unless an earlier attempt may have arrived, the // Mac never took it, and Try again is a new request. outbox[current].status = .failed - outbox[current].keepsKey = outbox[current].mayHaveArrived + outbox[current].keepsKey = outbox[current].keepsKey || outbox[current].mayHaveArrived releaseEcho(at: current) return } diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index d338ccd2c..bb60ca1b2 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -19,9 +19,17 @@ private final class TestGate: @unchecked Sendable { 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()`. - func hold() { lock.withLock { armed = true } } + /// 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() @@ -51,10 +59,11 @@ private final class TestGate: @unchecked Sendable { } /// 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 { - permits += 1 + if armed { permits += 1 } return nil } return waiters.removeValue(forKey: order.removeFirst()) @@ -113,17 +122,17 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke let stopGate = TestGate() /// Holds each Resume until `releaseResume()`. var holdResume: Bool { - get { false } + get { resumeGate.isArmed } set { if newValue { resumeGate.hold() } else { resumeGate.releaseAll() } } } /// Holds each message request until `releaseSend()`. var holdSend: Bool { - get { false } + get { sendGate.isArmed } set { if newValue { sendGate.hold() } else { sendGate.releaseAll() } } } /// Holds each Stop until `releaseStop()`. var holdStop: Bool { - get { false } + get { stopGate.isArmed } set { if newValue { stopGate.hold() } else { stopGate.releaseAll() } } } private var _stopResults: [Result] = [] @@ -132,6 +141,8 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke 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() @@ -179,8 +190,9 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke 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) } @@ -3041,6 +3053,254 @@ final class AidenBotSessionOptimisticTests: XCTestCase { XCTAssertEqual(model.outgoing.first?.status, .sent(submissionId: "17"), "the early release was not lost") transport.holdSend = false } + + // MARK: Fix round 4 + + /// The records of the one chat file under `root`, as stored. + private func diskRecords(_ root: URL) throws -> [[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 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). @@ -3424,3 +3684,17 @@ final class AidenBotChatActivityTests: XCTestCase { XCTAssertFalse(aidenBotShowsChatActivity(server: server(features: announced, grants: [.botActivity]), hasBaseline: false), "no Bot detail yet") } } + +/// 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 + } +} From 7049b040e93c548dffbe0b54e33758754ab1919f Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 17:43:14 -0400 Subject: [PATCH 29/83] iOS Bots: a refused message retries on a new key; unreadable outbox kept Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotSessionModel.swift | 95 ++++++++++--------- .../AidenBotSessionTests.swift | 84 +++++++++++++--- 2 files changed, 123 insertions(+), 56 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index 537801c9f..52e077015 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -283,9 +283,6 @@ struct AidenBotOutgoingMessage: Equatable, Identifiable, Sendable { fileprivate var connectionAttempts = 0 /// The last failure may have reached the Mac, so Try again keeps the key. fileprivate var keepsKey = false - /// An earlier attempt went out and ended without an answer (cut off, timed out - /// or cancelled): the Mac may hold it, whatever a later attempt says. - fileprivate var mayHaveArrived = false fileprivate var submissionId: String? { if case let .sent(id) = status { return id } @@ -359,15 +356,9 @@ final class AidenBotSessionCache { } /// Stores the outbox when `owner` writes this chat; false when another model does. - /// `mustOwn` also refuses a chat nobody holds any more (after an unpair). @discardableResult - func storeOutbox( - _ outbox: [AidenBotOutgoingMessage], - for key: AidenBotSessionKey, - owner: ObjectIdentifier, - mustOwn: Bool = false - ) -> Bool { - guard owners[key] == owner || (!mustOwn && owners[key] == nil) else { return false } + 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 @@ -420,7 +411,18 @@ actor AidenBotOutboxStore { } } - func take(_ key: AidenBotSessionKey) -> Write? { lock.withLock { writes.removeValue(forKey: key) } } + /// 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 { @@ -435,6 +437,8 @@ actor AidenBotOutboxStore { /// 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 static let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "sbtbiswas.AidenOnTheGo", category: "BotOutbox") private struct Record: Codable { @@ -451,7 +455,6 @@ actor AidenBotOutboxStore { let connectionAttempts: Int let keepsKey: Bool /// Absent in records written before fix round 4. - let mayHaveArrived: Bool? let foreignSubmissionIDs: [String]? } @@ -505,18 +508,28 @@ actor AidenBotOutboxStore { func restore(_ key: AidenBotSessionKey) -> (messages: [AidenBotOutgoingMessage], unreadable: Bool) { knownDevices[key.instanceId, default: []].insert(key.deviceId) let url = fileURL(key) - guard let data = try? Data(contentsOf: url) else { return ([], false) } + 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. + Self.log.error("event=outbox_unopenable outcome=set_aside") + setAside(url, for: key, keepingOriginal: false) + return ([], true) + } guard data.count <= 2 * Self.maxBytes else { // Never written this large. Self.log.error("event=outbox_oversized outcome=set_aside") - setAside(url, keepingOriginal: false) + 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 { Self.log.error("event=outbox_unreadable outcome=set_aside") - setAside(url, keepingOriginal: false) + setAside(url, for: key, keepingOriginal: false) return ([], true) } let readable = envelope.messages.compactMap(\.record) @@ -524,7 +537,7 @@ actor AidenBotOutboxStore { if lostSome { // The readable ones are written back; the file as it was is kept beside them. Self.log.error("event=outbox_record_unreadable outcome=set_aside count=\(envelope.messages.count - readable.count, privacy: .public)") - setAside(url, keepingOriginal: true) + setAside(url, for: key, keepingOriginal: true) } let cutoff = now().addingTimeInterval(-Self.lifetime) var changed = readable.count != envelope.messages.count @@ -548,7 +561,6 @@ actor AidenBotOutboxStore { message.baseline = Set(record.baseline) message.connectionAttempts = record.connectionAttempts message.keepsKey = record.keepsKey - message.mayHaveArrived = record.mayHaveArrived ?? false message.foreignSubmissionIDs = Set(record.foreignSubmissionIDs ?? []) if expired, message.status == .sending { message.expire() @@ -561,7 +573,8 @@ actor AidenBotOutboxStore { } /// Moves (or copies) a file this build can't fully read out of the chat's way. - private func setAside(_ url: URL, keepingOriginal: Bool) { + /// 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 { @@ -571,6 +584,7 @@ actor AidenBotOutboxStore { } } catch { Self.log.error("event=outbox_set_aside outcome=failed") + if !keepingOriginal { stuckFiles.insert(key) } } } @@ -584,6 +598,7 @@ actor AidenBotOutboxStore { 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) @@ -611,11 +626,14 @@ actor AidenBotOutboxStore { baseline: Array(message.baseline), connectionAttempts: message.connectionAttempts, keepsKey: message.keepsKey, - mayHaveArrived: message.mayHaveArrived, // 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 { + Self.log.error("event=outbox_write outcome=skipped_unread_file") + return + } if records.isEmpty { remove(key) return @@ -639,6 +657,7 @@ actor AidenBotOutboxStore { 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)) } @@ -904,9 +923,6 @@ final class AidenBotSessionModel { /// 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 - // Frozen and still unsettled: an attempt went out with no answer this - // visit saw (cut off, timed out, or still on its way), so the Mac may hold it. - if message.status == .sending, message.request != nil { message.mayHaveArrived = true } message.deferred = false message.dropEcho() message.queuedID = nil @@ -918,11 +934,10 @@ final class AidenBotSessionModel { now.timeIntervalSince(message.createdAt) >= AidenBotOutboxStore.lifetime } - /// The chat closed: sending and retry timers stop at once, and the unsent - /// messages stay for the next visit. What the cut-off request leaves behind - /// (it may have arrived) is still written, unless a newer visit holds the - /// chat. The store's own writer finishes a write already handed to it, so - /// the next launch sees the latest outbox. + /// 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() @@ -1269,13 +1284,11 @@ final class AidenBotSessionModel { // 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. Once - /// released it still records what its cut-off request left (it may have - /// arrived), but only while it still holds the chat. The store keeps one - /// ordered writer per 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, - cache.storeOutbox(outbox, for: cacheKey, owner: ObjectIdentifier(self), mustOwn: released) else { return } + guard let cacheKey, !released, + cache.storeOutbox(outbox, for: cacheKey, owner: ObjectIdentifier(self)) else { return } outboxStore?.submit(.save(outbox), for: cacheKey) } @@ -1398,9 +1411,6 @@ final class AidenBotSessionModel { // 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. - if let current = outbox.firstIndex(where: { $0.key == key }), released || error is CancellationError || Task.isCancelled { - outbox[current].mayHaveArrived = true - } guard !released, !(error is CancellationError), !Task.isCancelled, let current = outbox.firstIndex(where: { $0.key == key }) else { return } if Self.isAccessLoss(error) { @@ -1408,15 +1418,15 @@ final class AidenBotSessionModel { return } guard Self.messageFailureIsAmbiguous(error) else { - // Refused outright. Unless an earlier attempt may have arrived, the - // Mac never took it, and Try again is a new request. + // 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 = outbox[current].keepsKey || outbox[current].mayHaveArrived + outbox[current].keepsKey = false releaseEcho(at: current) return } outbox[current].keepsKey = true - outbox[current].mayHaveArrived = true outbox[current].connectionAttempts += 1 let attempts = outbox[current].connectionAttempts if attempts < Self.maxConnectionAttempts { @@ -1636,8 +1646,7 @@ final class AidenBotSessionModel { request: outbox[index].request, baseline: outbox[index].baseline, foreignSubmissionIDs: outbox[index].foreignSubmissionIDs, - keepsKey: outbox[index].keepsKey, - mayHaveArrived: outbox[index].keepsKey && outbox[index].mayHaveArrived + keepsKey: outbox[index].keepsKey ) await runFlush() return true diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index bb60ca1b2..62fc6a61b 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -112,6 +112,7 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke 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? @@ -162,6 +163,8 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke 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 } @@ -227,6 +230,7 @@ private final class FakeBotSessionTransport: AidenBotSessionTransport, @unchecke _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() @@ -3030,17 +3034,31 @@ final class AidenBotSessionOptimisticTests: XCTestCase { XCTAssertTrue(retried) XCTAssertEqual(transport.sendKeys.count, 2) XCTAssertEqual(transport.sendKeys[0], transport.sendKeys[1], "Try again replays the key the cut-off request used") + } - // And a definite refusal after an attempt that may have arrived keeps it too. + func testARefusedMessageRetriesOnANewKeyOnceTheBotIsFixed() async throws { + let transport = FakeBotSessionTransport() + let model = await loaded(transport, try session(.idle)) transport.failNextSend(with: URLError(.timedOut)) - transport.failNextSend(with: try serverError(status: 400, code: "invalid_request")) - _ = await model.send("Second") - transport.queueSession(.success(try session(.idle, seq: 7))) + _ = await model.send("Plan my week.") + // Meanwhile the Bot was paused: the Mac refuses the replay before running it. + transport.failNextSend(with: try serverError(status: 409, code: "operation_stale")) + transport.queueSession(.success(try session(.idle, seq: 5))) await model.load() - let refused = try XCTUnwrap(model.outgoing.last) + let refused = try XCTUnwrap(model.outgoing.first) XCTAssertEqual(model.look(of: refused), .failed) - _ = await model.retrySend(refused.id) - XCTAssertEqual(Set(transport.sendKeys.suffix(3)).count, 1) + XCTAssertEqual(transport.sendKeys.count, 2) + XCTAssertEqual(transport.sendKeys[0], transport.sendKeys[1], "the timed-out try was replayed under its key") + // The Mac's ledger now answers that key with an error, whatever the Bot's state. + transport.answerKey(transport.sendKeys[0], with: try serverError(status: 500, code: "internal_error")) + + // The person fixes the Bot and taps Try again. + _ = await model.apply(event(seq: 6, .state(try AidenBotSessionStateView(state: .idle)))) + let retried = await model.retrySend(refused.id) + XCTAssertTrue(retried) + XCTAssertEqual(transport.sendKeys.count, 3) + XCTAssertNotEqual(transport.sendKeys[2], transport.sendKeys[0], "Try again is a new request") + XCTAssertEqual(model.outgoing.first?.status, .sent(submissionId: "17"), "and it goes through") } func testAGateReleasedBeforeItsRequestKeepsTheRelease() async throws { @@ -3129,7 +3147,7 @@ final class AidenBotSessionOptimisticTests: XCTestCase { XCTAssertEqual(Set(transport.sendKeys).count, 1, "Try again replays the key") } - func testARequestCutOffByLeavingThenRefusedAfterARelaunchKeepsItsKey() async throws { + func testARequestCutOffByLeavingIsReplayedAfterARelaunchAndARefusalRetriesOnANewKey() async throws { let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) let (store, root) = temporaryStore() let transport = FakeBotSessionTransport() @@ -3149,14 +3167,17 @@ final class AidenBotSessionOptimisticTests: XCTestCase { transport.failNextSend(with: try serverError(status: 400, code: "invalid_request")) transport.queueSession(.success(try session(.idle, seq: 9))) await relaunched.load() + XCTAssertEqual(transport.sendKeys.count, 2) + XCTAssertEqual(transport.sendKeys[0], transport.sendKeys[1], "the next launch replays the key the cut-off request used") let refused = try XCTUnwrap(relaunched.outgoing.first) XCTAssertEqual(relaunched.look(of: refused), .failed) _ = await relaunched.retrySend(refused.id) XCTAssertEqual(transport.sendKeys.count, 3) - XCTAssertEqual(Set(transport.sendKeys).count, 1, "Try again replays the key the cut-off request used") + XCTAssertNotEqual(transport.sendKeys[2], transport.sendKeys[1], "a refusal is definite: Try again is a new request") + XCTAssertEqual(relaunched.outgoing.first?.status, .sent(submissionId: "17")) } - func testARequestThatTimedOutThenRefusedAfterARelaunchKeepsItsKey() async throws { + func testARequestThatTimedOutIsReplayedAfterARelaunchAndARefusalRetriesOnANewKey() async throws { let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) let (store, root) = temporaryStore() let transport = FakeBotSessionTransport() @@ -3172,14 +3193,17 @@ final class AidenBotSessionOptimisticTests: XCTestCase { transport.failNextSend(with: try serverError(status: 400, code: "invalid_request")) transport.queueSession(.success(try session(.idle, seq: 9))) await relaunched.load() + XCTAssertEqual(transport.sendKeys.count, 2) + XCTAssertEqual(transport.sendKeys[0], transport.sendKeys[1], "the next launch replays the key the timed-out request used") let refused = try XCTUnwrap(relaunched.outgoing.first) XCTAssertEqual(relaunched.look(of: refused), .failed) _ = await relaunched.retrySend(refused.id) XCTAssertEqual(transport.sendKeys.count, 3) - XCTAssertEqual(Set(transport.sendKeys).count, 1, "Try again replays the key the timed-out request used") + XCTAssertNotEqual(transport.sendKeys[2], transport.sendKeys[1], "a refusal is definite: Try again is a new request") + XCTAssertEqual(relaunched.outgoing.first?.status, .sent(submissionId: "17")) } - func testARequestCutOffByClosingTheChatThenRefusedOnTheNextVisitKeepsItsKey() async throws { + func testARequestCutOffByClosingTheChatIsReplayedOnTheNextVisitAndARefusalRetriesOnANewKey() async throws { let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) let cache = AidenBotSessionCache() let transport = FakeBotSessionTransport() @@ -3196,11 +3220,14 @@ final class AidenBotSessionOptimisticTests: XCTestCase { transport.failNextSend(with: try serverError(status: 400, code: "invalid_request")) transport.queueSession(.success(try session(.idle, seq: 9))) await second.load() + XCTAssertEqual(transport.sendKeys.count, 2) + XCTAssertEqual(transport.sendKeys[0], transport.sendKeys[1], "the next visit replays the key the cut-off request used") let refused = try XCTUnwrap(second.outgoing.first) XCTAssertEqual(second.look(of: refused), .failed) _ = await second.retrySend(refused.id) XCTAssertEqual(transport.sendKeys.count, 3) - XCTAssertEqual(Set(transport.sendKeys).count, 1, "Try again replays the key the cut-off request used") + XCTAssertNotEqual(transport.sendKeys[2], transport.sendKeys[1], "a refusal is definite: Try again is a new request") + XCTAssertEqual(second.outgoing.first?.status, .sent(submissionId: "17")) } func testACancelledCallerNeverLeavesItsRequestRunning() async throws { @@ -3289,6 +3316,37 @@ final class AidenBotSessionOptimisticTests: XCTestCase { } } + 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() From 4061cf70761c14e38d3ea7f7126837d424193cc0 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 17:45:17 -0400 Subject: [PATCH 30/83] Fixture: null-literal rejected samples for revision 30 botActivityPrivacy.rejectedNullLiterals holds activities (app: null), sessions (queued: null), events (a queued frame with queued: null) and entries (label: null with submissionId). Each decoder must refuse them; the TS parser does, and the protocol test pins each reason. Co-Authored-By: Claude Opus 5.5 --- main/services/aiden-remote-protocol.test.ts | 27 ++++++++++++++++ .../aiden-remote/v1/fixtures/contract.json | 31 ++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/main/services/aiden-remote-protocol.test.ts b/main/services/aiden-remote-protocol.test.ts index 575011037..018978194 100644 --- a/main/services/aiden-remote-protocol.test.ts +++ b/main/services/aiden-remote-protocol.test.ts @@ -2460,6 +2460,33 @@ test("Bot activity frames carry only the §11 allow-list, and every privacy samp } }); +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; + assert.equal(parseAidenRemoteBotSessionEntry(withoutLabel).type === "message" && parseAidenRemoteBotSessionEntry(withoutLabel).submissionId, "17"); + } +}); + 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); diff --git a/protocol/aiden-remote/v1/fixtures/contract.json b/protocol/aiden-remote/v1/fixtures/contract.json index b0412a333..69534bb6c 100644 --- a/protocol/aiden-remote/v1/fixtures/contract.json +++ b/protocol/aiden-remote/v1/fixtures/contract.json @@ -2299,6 +2299,35 @@ { "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" } + ] + } } } From 249e97c5967a9b6dfe328432cb81a1aee36fa443 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 17:50:19 -0400 Subject: [PATCH 31/83] Remote Bots: a send refused from state leaves no ledger record (R22b) A Bot session that refuses before doing anything (no host, no model or one that can't run, a paused turn) no longer records the request as a rejected operation. Remote send, Resume, Dismiss and Stop wrap the runtime call so such a BotSessionError becomes AidenOperationNotExecutedError, which the idempotency ledger discards and unwraps. A retry with the same key after the Bot is fixed is evaluated afresh instead of replaying internal_error for a day. Any other failure keeps its key and replays, so nothing runs twice. Co-Authored-By: Claude Opus 5.5 --- main/services/aiden-remote-bot-session.ts | 48 ++++++++-- .../aiden-remote-operation-contract.ts | 18 ++++ main/services/aiden-remote-router.test.ts | 87 +++++++++++++++++++ 3 files changed, 147 insertions(+), 6 deletions(-) diff --git a/main/services/aiden-remote-bot-session.ts b/main/services/aiden-remote-bot-session.ts index 71dbfe8fc..aa22a7464 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -98,7 +98,8 @@ 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 { 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 { @@ -970,7 +971,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) { @@ -1531,12 +1563,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, { + 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)) }; }, ); @@ -1563,11 +1595,15 @@ 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)); + 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 = () => runtime.stop(bot.id); + const stop = () => unlessRefusedBeforeRunning(() => runtime.stop(bot.id)); const state = this.options.turnIntents ? await stopWithIntents(this.options.turnIntents, bot.id, stop) : await stop(); 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-router.test.ts b/main/services/aiden-remote-router.test.ts index 7c26196e5..48718c283 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -23,6 +23,8 @@ import { } 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"; @@ -5238,3 +5240,88 @@ test("a bubble's submissionId reaches only a device holding bot:activity, on a f } 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 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 refusal from state, before anything is written. + 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" }); + 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(); + } +}); From c927462dfe451835fa7263f88f36c31f72021d77 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 17:53:32 -0400 Subject: [PATCH 32/83] Protocol test: narrow the null-literal entry before reading its id Co-Authored-By: Claude Opus 5.5 --- main/services/aiden-remote-protocol.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/main/services/aiden-remote-protocol.test.ts b/main/services/aiden-remote-protocol.test.ts index 018978194..3ea25537b 100644 --- a/main/services/aiden-remote-protocol.test.ts +++ b/main/services/aiden-remote-protocol.test.ts @@ -2483,7 +2483,8 @@ test("an optional Bot activity key sent as the literal null is refused, never re for (const sample of entries) { assert.throws(() => parseAidenRemoteBotSessionEntry(sample), /Only the person's message can carry a submissionId/u); const { label: _label, ...withoutLabel } = sample; - assert.equal(parseAidenRemoteBotSessionEntry(withoutLabel).type === "message" && parseAidenRemoteBotSessionEntry(withoutLabel).submissionId, "17"); + const entry = parseAidenRemoteBotSessionEntry(withoutLabel); + assert.equal(entry.type === "message" && entry.submissionId, "17", "the same bubble without the key keeps its id"); } }); From e2f678024c33716d9f4b343df53b205fce4a9536 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 17:56:17 -0400 Subject: [PATCH 33/83] Android Bots: unreadable outboxes set aside, bounded requests, write-counted answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FR-I2: an outbox file that can't be read (unopenable, oversized, damaged, a newer version or another pairing's) is renamed aside as .unreadable, never deleted; bad records keep a copy of the file. restoreOutbox returns an unreadable flag and the chat says "Some unsent messages couldn’t be restored." A file that can't be moved is never written over. - FR-I3: every Bot session request (send, stop, resume, dismiss, answers, approvals, proposals, connection requests and the session GET) is bounded to 12 s; a timeout is ambiguous, so the key is kept. - FR-m2: deliver, stop, resume and dismiss apply the Mac's answer unless the Mac wrote the state since the tap (stateWrites), not cursor == mark. - FR-m4: the access-lost, outbox-full and unrestorable copy lives in strings.xml and matches iOS; the separator too. - FR-m5: a taken message nothing on the Mac shows goes on a second snapshot or after a 10 s grace, and never while the Mac's Queued list is full. - Controller polish: the disk is read outside the store lock and merged under it; the restore continuation always resumes; a merge past the caps drops nothing (the newest unsent say Couldn't send, and no take() cuts the file); deliver stops when the store refused the frozen request; a cleared activity keeps Resume's word; a frozen request refused for the Bot's state keeps its key; the four memory-store tests run io inline. Co-Authored-By: Claude Opus 5.5 --- .../features/bots/AidenBotOutbox.kt | 139 +++++-- .../bots/AidenBotSessionController.kt | 194 +++++++-- .../features/bots/AidenBotSessionScreen.kt | 3 +- android/app/src/main/res/values/strings.xml | 5 + .../bots/AidenBotSessionControllerTest.kt | 371 +++++++++++++++++- 5 files changed, 620 insertions(+), 92 deletions(-) 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 index dd74b726c..141d7f349 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt @@ -14,7 +14,7 @@ import java.security.MessageDigest import java.util.UUID import java.util.concurrent.Executor import java.util.concurrent.Executors -import kotlin.coroutines.resume +import java.util.concurrent.RejectedExecutionException /** * Names one Bot chat across controllers and launches: its Mac, this pairing's device and the @@ -71,7 +71,9 @@ data class AidenBotOutgoingMessage( /** 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 + 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 @@ -83,7 +85,7 @@ data class AidenBotOutgoingMessage( /** 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) + 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 } @@ -109,6 +111,9 @@ data class AidenBotOutgoingMessage( } } +/** 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) + /** How the chat draws an outgoing message (R14). */ sealed class AidenBotOutgoingLook { /** Faded, "Sending…". */ @@ -135,13 +140,18 @@ class AidenBotSessionStore( private val io: Executor = defaultIo, private val nowMs: () -> Long = System::currentTimeMillis, private val report: (String) -> Unit = {}, - /** The largest outbox file a later launch reads back; one past it is refused at enqueue. */ + /** + * 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() @Synchronized fun transcript(key: AidenBotSessionKey): List? = transcripts[key] @@ -164,32 +174,46 @@ class AidenBotSessionStore( /** * 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. Disk reads, writes and purges all run in order on [io], - * so a purge that came first leaves nothing to read. Empty once [owner] no longer writes it. + * 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): List = + suspend fun restoreOutbox(key: AidenBotSessionKey, owner: Any): AidenBotOutboxRestore = suspendCancellableCoroutine { continuation -> - io.execute { - val messages = synchronized(this) { - when { - owners[key] !== owner -> emptyList() - read.add(key) -> { - // What was sent before this read waited in memory; it follows the older disk copy. - val disk = load(key) - val known = disk.mapTo(HashSet()) { it.key } - val pending = outboxes[key].orEmpty().filterNot { it.key in known } - val merged = disk + pending - if (merged.isEmpty()) outboxes.remove(key) else outboxes[key] = merged - if (pending.isNotEmpty()) write(key, merged.map(Record::of)) - merged - } - else -> outboxes[key].orEmpty() - } - } - continuation.resume(messages) + 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 @@ -216,6 +240,7 @@ class AidenBotSessionStore( transcripts.remove(key) outboxes.remove(key) read += key + stuck -= key io.execute { fileFor(key)?.let { AtomicFile(it).delete() } } } @@ -229,6 +254,7 @@ class AidenBotSessionStore( transcripts.keys.removeAll { it.instanceId == instanceId } outboxes.keys.removeAll { it.instanceId == instanceId } owners.keys.removeAll { it.instanceId == instanceId } + stuck.removeAll { it.instanceId == instanceId } val dir = root?.let { File(it, digest(instanceId)) } io.execute { dir?.deleteRecursively() } } @@ -306,28 +332,35 @@ class AidenBotSessionStore( 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. One still unsent after 24 hours comes back as not sent; a taken one that old is - * the Mac's now. + * 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): List { - val file = fileFor(key) ?: return emptyList() + 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 emptyList() + if (!file.exists() && !File(file.path + ".bak").exists()) return nothing val envelope = try { val bytes = atomic.readFully() - if (bytes.size > maxBytes) throw IllegalStateException("too large") + // 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=dropped") - atomic.delete() - return emptyList() + 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 { @@ -336,8 +369,10 @@ class AidenBotSessionStore( null } } - if (readable.size != envelope.messages.size) { - report("event=bot_outbox_record_unreadable outcome=dropped count=${envelope.messages.size - readable.size}") + 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 -> @@ -347,12 +382,33 @@ class AidenBotSessionStore( message.status == AidenBotOutgoingStatus.SENDING -> message.expired() else -> message } - }.take(MAX_MESSAGES) - if (messages != readable) { - val records = messages.map(Record::of) - io.execute { write(key, records) } } - return messages + 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 { @@ -363,6 +419,7 @@ class AidenBotSessionStore( /** 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() 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 995d4d83a..863e268ec 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 @@ -216,6 +219,22 @@ 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 || @@ -252,6 +271,8 @@ data class AidenBotSessionUiState( 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(), @@ -395,6 +416,10 @@ class AidenBotSessionController( 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 @@ -405,6 +430,9 @@ class AidenBotSessionController( 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 private var released = false @@ -470,8 +498,19 @@ class AidenBotSessionController( 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) } + } + + /** [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. @@ -481,11 +520,24 @@ class AidenBotSessionController( val key = sessionKey ?: return val store = store ?: return restoreJob = scope.launch { - val stored = store.restoreOutbox(key, this@AidenBotSessionController) + 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) + } val now = nowMs() + val stored = restore.messages val known = stored.mapTo(HashSet()) { it.key } restored = true - setOutbox(stored.map { it.adopted(now) } + _state.value.outbox.filterNot { it.key in known }, force = 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() } @@ -499,7 +551,7 @@ class AidenBotSessionController( private suspend fun load(authoritative: Boolean): Boolean { return try { - val session = transport.session(botId) + 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) } @@ -599,8 +651,8 @@ class AidenBotSessionController( /** * 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. Any - * activity after a Resume tap says what the resumed run is doing. + * `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 @@ -609,7 +661,7 @@ class AidenBotSessionController( (next != null && (stopRun == null || next.startedAt != stopRun)) current.copy( stopIntent = if (clearStop) AidenBotTurnIntent.NONE else current.stopIntent, - resumeIntent = AidenBotTurnIntent.NONE + resumeIntent = if (next != null) AidenBotTurnIntent.NONE else current.resumeIntent ) } } @@ -697,7 +749,7 @@ class AidenBotSessionController( 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) } + _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() @@ -712,19 +764,45 @@ class AidenBotSessionController( 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()) - val fits = next.size <= AidenBotSessionStore.MAX_MESSAGES && - (sessionKey == null || store == null || store.fits(sessionKey, next)) - if (!fits) { - _state.update { it.copy(actionError = OUTBOX_FULL) } + 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, @@ -736,7 +814,8 @@ class AidenBotSessionController( echoed = false, echoEntryId = null, echoSubmissionId = null, - queuedId = null + queuedId = null, + unheld = false ) setOutbox(_state.value.outbox.map { if (it.id == id) again else it }) flushNow() @@ -787,15 +866,15 @@ class AidenBotSessionController( setOutbox(_state.value.outbox.mapNotNull { if (it.key == key) change(it) else it }) } - private fun cursor(): String = _state.value.session.let { "${it?.epoch}\u001f${it?.seq}" } - /** * 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 snapshot - * also drops a taken message that is neither queued nor in the transcript. + * 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 goes once a later snapshot still + * finds it so, or [unheldGraceMillis] after the first one did (FR-m5): the Mac's Queued list + * may be full, or may leave a message out while it can't yet tell whose it is. */ private fun reconcileOutbox(snapshot: Boolean) { val current = _state.value @@ -805,6 +884,7 @@ class AidenBotSessionController( claimedEntries.clear() } if (current.outbox.isEmpty()) return + val queueFull = current.queued.size >= AidenBotQueuedMessage.MAX_COUNT val entries = current.session?.entries.orEmpty() val owned = current.outbox.mapNotNull { it.submissionId }.toSet() val queuedIds = current.queued.map { it.submissionId }.toSet() @@ -829,10 +909,16 @@ class AidenBotSessionController( val id = message.submissionId if (id != null) { if (id in queuedIds) { - message = message.copy(queuedId = id) - } else if (snapshot && current.activitySupported) { - // Taken, not queued and not in the transcript: nothing on the Mac holds it. - continue + 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. + when { + // A full queue can't show it; it stays until the Mac's view has room. + queueFull -> message = message.copy(unheld = false) + // A second snapshot agrees, or the grace period ran out ([unheldTimers]). + message.unheld && (snapshot || message.key in unheldExpired) -> continue + snapshot -> message = message.copy(unheld = true) + } } } else if (message.queuedId == null) { // The Queued frame can beat the receipt. @@ -846,6 +932,27 @@ class AidenBotSessionController( 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. */ @@ -921,7 +1028,12 @@ class AidenBotSessionController( val current = _state.value val message = current.outbox.firstOrNull { it.key == key } ?: return if (current.needsModel || current.state == AidenBotSessionState.UNAVAILABLE || current.botMissing) { - updateMessage(key) { it.copy(status = AidenBotOutgoingStatus.FAILED, echoed = false) } + // 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())) { @@ -936,11 +1048,13 @@ class AidenBotSessionController( baseline = current.session?.entries?.map { it.id }?.toSet().orEmpty() ) if (request != message) updateMessage(key) { request } - val mark = cursor() + // 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 = transport.send(botId, request.text, key, request.sentMode) - if (cursor() == mark) applyStateView(receipt.stateView) + 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) { @@ -1022,7 +1136,7 @@ class AidenBotSessionController( backoffJob?.cancel() setOutbox(emptyList()) sessionKey?.let { store?.purge(it) } - if (hadUnsent) _state.update { it.copy(actionError = ACCESS_LOST) } + 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. */ @@ -1049,11 +1163,10 @@ class AidenBotSessionController( actionError = null ) } - val mark = cursor() return try { - val view = transport.resume(botId, keys.key(RESUME)) + val view = bounded { transport.resume(botId, keys.key(RESUME)) } keys.complete(RESUME) - if (cursor() == mark) applyStateView(view) + if (stateWrites == writes) applyStateView(view) _state.update { if (it.resumeIntent == AidenBotTurnIntent.REQUESTED) it.copy(resumeIntent = AidenBotTurnIntent.ACKNOWLEDGED) else it } @@ -1090,11 +1203,10 @@ class AidenBotSessionController( actionError = null ) } - val mark = cursor() return try { - val view = transport.dismiss(botId, keys.key(DISMISS)) + val view = bounded { transport.dismiss(botId, keys.key(DISMISS)) } keys.complete(DISMISS) - if (cursor() == mark) applyStateView(view) + if (stateWrites == writes) applyStateView(view) true } catch (error: CancellationException) { throw error @@ -1128,11 +1240,11 @@ class AidenBotSessionController( } else { _state.update { it.copy(actionError = null) } } - val mark = cursor() + val writes = stateWrites return try { - val view = transport.stop(botId, keys.key(STOP)) + val view = bounded { transport.stop(botId, keys.key(STOP)) } keys.complete(STOP) - if (cursor() == mark) applyStateView(view) + if (stateWrites == writes) applyStateView(view) _state.update { if (it.stopIntent == AidenBotTurnIntent.REQUESTED) it.copy(stopIntent = AidenBotTurnIntent.ACKNOWLEDGED) else it } @@ -1167,7 +1279,7 @@ class AidenBotSessionController( 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 true @@ -1214,7 +1326,7 @@ 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 true @@ -1247,7 +1359,7 @@ class AidenBotSessionController( 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) { @@ -1290,7 +1402,7 @@ class AidenBotSessionController( 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 @@ -1355,8 +1467,10 @@ class AidenBotSessionController( 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." - const val ACCESS_LOST = "That message wasn’t sent. Please try again." - const val OUTBOX_FULL = "Too many messages are waiting or didn’t send. Send or remove some, then 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 b943ce35b..fd5162ccf 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 @@ -427,7 +427,8 @@ 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) } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 02aa4838b..fb0f7c7cb 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -675,6 +675,11 @@ 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 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 9e349d2df..5f8ecf940 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 @@ -406,7 +406,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) @@ -478,7 +479,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) @@ -1310,13 +1311,15 @@ class AidenBotSessionControllerTest { other.start() runCurrent() assertTrue(other.state.value.outbox.isEmpty()) - assertFalse("the misplaced file is dropped", theirs.exists()) + 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) + val sessions = AidenBotSessionStore(null, io = { it.run() }) val transport = FakeTransport(runningSession) val older = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) older.refetch() @@ -1328,7 +1331,7 @@ class AidenBotSessionControllerTest { @Test fun leavingTheChatCancelsTheRequestUnderWayAndTheNextVisitReplaysItsKey() = runTest { - val sessions = AidenBotSessionStore(null) + val sessions = AidenBotSessionStore(null, io = { it.run() }) val transport = FakeTransport(runningSession) val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) controller.refetch() @@ -1361,7 +1364,7 @@ class AidenBotSessionControllerTest { while (controller.send(text) && taken < 20) taken += 1 runCurrent() assertTrue("some fit, then one was refused", taken in 1..5) - assertEquals(AidenBotSessionController.OUTBOX_FULL, controller.state.value.actionError) + assertEquals(AidenBotSessionNotice.OUTBOX_FULL, controller.state.value.notice) assertEquals(taken, controller.state.value.outbox.size) // What was kept reads back whole. val relaunched = AidenBotSessionController( @@ -1398,6 +1401,10 @@ class AidenBotSessionControllerTest { 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. @@ -1426,7 +1433,7 @@ class AidenBotSessionControllerTest { controller.start() runCurrent() assertTrue("$status", controller.state.value.outbox.isEmpty()) - assertEquals("That message wasn’t sent. Please try again.", controller.state.value.actionError) + 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)) } @@ -1450,7 +1457,7 @@ class AidenBotSessionControllerTest { ) ) assertTrue(controller.state.value.outbox.isEmpty()) - assertEquals("That message wasn’t sent. Please try again.", controller.state.value.actionError) + 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( @@ -1467,7 +1474,7 @@ class AidenBotSessionControllerTest { @Test fun aChatOpensOnItsCachedTranscriptAndShowsSkeletonsOnlyWithNothingToShow() = runTest { - val sessions = AidenBotSessionStore(null) + 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) @@ -1513,7 +1520,7 @@ class AidenBotSessionControllerTest { @Test fun aCachedChatShowsItsTranscriptWithConnectingAndAMissingBotShowsNeither() = runTest { - val sessions = AidenBotSessionStore(null) + val sessions = AidenBotSessionStore(null, io = { it.run() }) val transport = FakeTransport(runningSession) val first = AidenBotSessionController(runningSession.botId, transport, backgroundScope, sessionKey = chatKey, store = sessions) first.refetch() @@ -1819,6 +1826,9 @@ class AidenBotSessionControllerTest { 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) @@ -1851,4 +1861,345 @@ class AidenBotSessionControllerTest { 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 goes only on a second look, or after the grace period. + @Test + fun aTakenMessageNothingOnTheMacShowsGoesOnlyOnASecondLookOrAfterItsGrace() = runTest { + val transport = FakeTransport(runningSession) + transport.receiptIds += listOf("21", "22", "23") + 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))) + } + + // A second snapshot agrees: it goes. + assertTrue(controller.send("Plan lunch")) + snapshot() + assertEquals("one look may miss it", listOf("Plan lunch"), controller.state.value.outgoing.map { it.text }) + snapshot() + assertTrue(controller.state.value.outbox.isEmpty()) + + // Or the grace period runs out. + assertTrue(controller.send("And dinner")) + snapshot() + advanceTimeBy(AidenBotSessionController.UNHELD_GRACE_MILLIS - 1) + runCurrent() + assertEquals(listOf("And dinner"), controller.state.value.outgoing.map { it.text }) + advanceTimeBy(2) + runCurrent() + assertTrue(controller.state.value.outbox.isEmpty()) + + // A full queue can't show it: it stays. + assertTrue(controller.send("And breakfast")) + val full = List(AidenBotQueuedMessage.MAX_COUNT) { AidenBotQueuedMessage("q$it", AidenStreamInputMode.QUEUE, "Other $it") } + snapshot(full) + snapshot(full) + advanceTimeBy(AidenBotSessionController.UNHELD_GRACE_MILLIS + 1) + runCurrent() + assertEquals(listOf("And breakfast"), controller.state.value.outgoing.map { it.text }) + } + + // 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")}" + ) + } } From 65103512b38a60fd530c794187a6eed343878d10 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 17:59:30 -0400 Subject: [PATCH 34/83] Bots: one run start per process, shared by the desktop feed and phones (FR-I1, FR-m6) A Remote projector took the run's start time when it was created, so a phone that opened mid-run, or came back after the 60 s idle close, showed a reset clock. createBotRunStarts keeps each Bot's current run start, keyed by liveRunKey and recorded the first time any surface sees the run; botRunStarts is the process-wide one that the desktop live projection and the Remote session service share. A surface that sees the Bot not running ends the record, so the next run (or a Resume) starts afresh. The projector's classification pause now runs on the injected clock instead of Date.now. Co-Authored-By: Claude Opus 5.5 --- .../services/aiden-remote-bot-session.test.ts | 91 +++++++++++++++++++ main/services/aiden-remote-bot-session.ts | 26 +++++- main/services/aiden-remote-service-main.ts | 4 +- main/services/bot-runtime/bot-session-main.ts | 3 +- .../bot-runtime/bot-turn-intents-main.ts | 5 + main/services/bot-runtime/live-projection.ts | 53 ++++++++++- 6 files changed, 175 insertions(+), 7 deletions(-) diff --git a/main/services/aiden-remote-bot-session.test.ts b/main/services/aiden-remote-bot-session.test.ts index 2dbe66f94..560ac47fb 100644 --- a/main/services/aiden-remote-bot-session.test.ts +++ b/main/services/aiden-remote-bot-session.test.ts @@ -25,6 +25,7 @@ import type { AskUserQuestionV1 } from "../../renderer/shared/ask-user-question. 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, parseAidenRemoteBotSession, @@ -1519,6 +1520,54 @@ test("a follow-up that replaces the run while the Bot stays running gets its own } }); +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 }); @@ -1655,6 +1704,48 @@ test("a waiting input that can't be classified stays off phones and is retried, } }); +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); diff --git a/main/services/aiden-remote-bot-session.ts b/main/services/aiden-remote-bot-session.ts index aa22a7464..1c904ed6e 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -108,7 +108,9 @@ import { liveQueuedInputIds, liveQueuedMessages, liveRunKey, + createBotRunStarts, projectBotTranscript, + type BotRunStarts, } from "./bot-runtime/live-projection.js"; import { botRoutineLabel, @@ -187,6 +189,12 @@ export interface AidenRemoteBotSessionServiceOptions { 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[]; @@ -497,6 +505,8 @@ interface ProjectorActivity { /** `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; } @@ -723,13 +733,17 @@ class BotLiveProjector { 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.runStartedAt = this.activityDeps!.now(); 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; @@ -739,6 +753,7 @@ class BotLiveProjector { } 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; @@ -778,7 +793,7 @@ class BotLiveProjector { const ids = liveQueuedInputIds(docs); for (const id of [...this.inputKinds.keys()]) if (!ids.includes(id)) this.inputKinds.delete(id); const unknown = ids.filter((id) => !this.inputKinds.has(id)); - if (unknown.length > 0 && Date.now() >= this.classifyNotBefore) { + if (unknown.length > 0 && deps.now() >= this.classifyNotBefore) { try { const answers = await deps.classify(unknown); for (const id of unknown) { @@ -841,8 +856,8 @@ class BotLiveProjector { /** One retry after the pause, on the serial lane, even if no frame arrives. */ private scheduleClassifyRetry(): void { - const { retryMs } = this.activityDeps!; - this.classifyNotBefore = Math.max(this.classifyNotBefore, Date.now() + retryMs); + const { retryMs, now } = this.activityDeps!; + this.classifyNotBefore = Math.max(this.classifyNotBefore, now() + retryMs); if (this.classifyRetry !== undefined || this.closed) return; this.classifyRetry = setTimeout(() => { this.classifyRetry = undefined; @@ -1104,8 +1119,10 @@ export class AidenRemoteBotSessionService { /** 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); @@ -1431,6 +1448,7 @@ export class AidenRemoteBotSessionService { 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), } diff --git a/main/services/aiden-remote-service-main.ts b/main/services/aiden-remote-service-main.ts index deacada1e..c9c6219b3 100644 --- a/main/services/aiden-remote-service-main.ts +++ b/main/services/aiden-remote-service-main.ts @@ -125,7 +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 { botTurnIntents } from "./bot-runtime/bot-turn-intents-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 { @@ -884,6 +884,8 @@ async function createRuntime(): Promise { 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, diff --git a/main/services/bot-runtime/bot-session-main.ts b/main/services/bot-runtime/bot-session-main.ts index b61b636dc..b4faf9211 100644 --- a/main/services/bot-runtime/bot-session-main.ts +++ b/main/services/bot-runtime/bot-session-main.ts @@ -44,7 +44,7 @@ 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 { botTurnIntents } from "./bot-turn-intents-main.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 { @@ -359,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), }); diff --git a/main/services/bot-runtime/bot-turn-intents-main.ts b/main/services/bot-runtime/bot-turn-intents-main.ts index 3f79b4cc3..863ffabd0 100644 --- a/main/services/bot-runtime/bot-turn-intents-main.ts +++ b/main/services/bot-runtime/bot-turn-intents-main.ts @@ -2,9 +2,14 @@ // 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. diff --git a/main/services/bot-runtime/live-projection.ts b/main/services/bot-runtime/live-projection.ts index 3ab7b37c4..c2e851035 100644 --- a/main/services/bot-runtime/live-projection.ts +++ b/main/services/bot-runtime/live-projection.ts @@ -424,6 +424,45 @@ export function liveRunKey(docs: Readonly>): string | nu 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; @@ -572,6 +611,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. */ @@ -668,6 +713,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(); @@ -778,7 +824,8 @@ 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.frame.docs["pi.live"]; @@ -869,6 +916,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) { @@ -907,6 +956,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); @@ -955,6 +1005,7 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro queue: Promise.resolve(), }; 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); From b0d8cfc4c841a6af41bf80c149f31ce9d5dd78e4 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 17:59:51 -0400 Subject: [PATCH 35/83] =?UTF-8?q?iOS=20Bots:=20final=20wave=20=E2=80=94=20?= =?UTF-8?q?snapshot=20grace,=20cached=20capabilities,=20still=20badge,=20c?= =?UTF-8?q?hat=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FR-m5: a taken message a snapshot doesn't show is marked unheld and goes only once 10 s pass with nothing holding it; seen again, it is held again. - R21: the pairing persists whether the Mac announced bot-activity-v1; with the persisted bot:activity grant it stands in until /server answers, so an offline open keeps the line, Steer/Queue and the Chat activity row. A frozen send replays byte for byte. - R19: under Reduce Motion or Low Power a canonical face wears the still glance badge; the glance timeline is capped at 30 fps. - FR-m1: the status line's clock ticks only while the line can show; the announcer resets when it hides. FR-m4: "Couldn’t send" with the curly apostrophe. FR-m7: Chat activity requests race through aidenBotWithin; AidenBotOutboxStore.load is now a test-only seam. - Chat polish: 44 pt Try again and remove targets, the Steer/Queue menu works with an empty draft, waiting bubbles use a 4.5:1 text token instead of opacity, scaled marks and composer controls, a stacked composer at accessibility sizes, a start failure shows Try again, skeletons stop shimmering when the Mac is unreachable, release no longer waits on the Bot detail read, follow-scroll only at the end or after an own send. - Chat activity: running steps use the Mac's phase words, a timeout or transport failure re-reads the Bot so a late success shows as saved, offline and conflict failures have their own copy, saves that land after Advanced closed still reach the screens behind, previews pause by onAppear/onDisappear inside the Form, and the tests' polls are bounded. - Fixture: the revision-30 null-literal samples are refused. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotAdvancedView.swift | 208 +++++++++---- .../AidenBotGeneratedAvatarLifecycle.swift | 18 +- .../Bots/AidenBotSemanticAvatarView.swift | 22 +- .../Bots/AidenBotSessionChatView.swift | 287 +++++++++++++---- .../Features/Bots/AidenBotSessionModel.swift | 72 +++-- .../Models/AidenInstallation.swift | 16 + .../Networking/AidenRemoteClient.swift | 7 +- .../AidenBotSessionTests.swift | 293 +++++++++++++++++- .../AidenRemoteClientTests.swift | 54 ++++ 9 files changed, 809 insertions(+), 168 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift index f68ab54b6..e82c48a23 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift @@ -31,8 +31,8 @@ struct AidenBotAdvancedView: View { @State private var isConfirmingDiscard = false @State private var chatActivityModel: AidenBotChatActivityModel? @State private var isShowingChatActivity = false - /// A Chat activity pick saved here, not yet reported to the screens behind Advanced. - @State private var chatActivityChanged = 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) @@ -86,12 +86,11 @@ struct AidenBotAdvancedView: View { reset(for: expectedSession) await load(for: expectedSession) } + .onAppear { chatActivityReport.advancedOpened() } .onDisappear { - // Closing Advanced (not opening Chat activity) reports a saved pick once. - if chatActivityChanged, !isShowingChatActivity, let baselineBot { - chatActivityChanged = false - onSaved(baselineBot) - } + // Closing Advanced (not opening Chat activity) reports a saved pick once; + // one that lands later is reported when it lands. + if !isShowingChatActivity { chatActivityReport.advancedClosed(reportBehind: onSaved) } } .alert( "Couldn’t Save", @@ -242,7 +241,11 @@ struct AidenBotAdvancedView: View { /// `bot:activity`. It opens from the cached Bot, so it stays reachable offline. @ViewBuilder private var chatActivitySection: some View { - if aidenBotShowsChatActivity(server: coordinator.server, hasBaseline: baselineBot != nil), let baselineBot { + if aidenBotShowsChatActivity( + server: coordinator.server, + installation: coordinator.installationStore.activeInstallation, + hasBaseline: baselineBot != nil + ), let baselineBot { Section { Button { openChatActivity(baselineBot) @@ -282,12 +285,12 @@ struct AidenBotAdvancedView: View { let client = try coordinator.remoteClient(for: try coordinator.requestContext()) return try await client.bot(id: botID) }, - onSaved: { saved in + onSaved: { [report = chatActivityReport, onSaved] saved in // Advanced's own Save, and its summary row, move to the newest revision. - // The screens behind hear about it once, when Advanced closes. + // The screens behind hear about it once Advanced closes, or at once after. baselineBot = saved - chatActivityChanged = true cacheDetail(saved) + report.saved(saved, reportBehind: onSaved) }, waitForConnection: { hold in await aidenBotWaitForConnection(coordinator, hold: hold) } ) @@ -441,7 +444,7 @@ struct AidenBotAdvancedView: View { isConfirmingDiscard = false chatActivityModel = nil isShowingChatActivity = false - chatActivityChanged = false + chatActivityReport = AidenBotChatActivityReport() } @MainActor @@ -549,7 +552,7 @@ struct AidenBotAdvancedView: View { self.baselineBot = latest } guard isCurrent(attempt) else { return } - chatActivityChanged = false + chatActivityReport.reportedByAdvanced() onSaved(latest) dismiss() } catch is CancellationError { @@ -576,7 +579,7 @@ struct AidenBotAdvancedView: View { self.catalog = refreshedCatalog self.draft = rebasedDraft if (try? rebasedDraft.isSatisfied(by: authoritative, catalog: refreshedCatalog)) == true { - chatActivityChanged = false + chatActivityReport.reportedByAdvanced() onSaved(authoritative) dismiss() } else { @@ -745,9 +748,10 @@ struct AidenBotModelListView: View { // 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. -func aidenBotShowsChatActivity(server: AidenServer?, hasBaseline: Bool) -> Bool { - hasBaseline && server?.supportsBotActivity == true +/// 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`). @@ -779,6 +783,16 @@ enum AidenBotChatActivityCopy { 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 { @@ -841,7 +855,10 @@ enum AidenBotChatActivityPreview { private static let phases: [String?] = ["thinking", "reading", "searching", "editing", nil] private static let stepLabels = ["Read 1 file", "Searched the web", "Edited 1 file"] - private static let runningLabels = ["Reading 1 file…", "Searching the web…", "Editing 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. @@ -880,11 +897,61 @@ enum AidenBotChatActivityPreview { } } -/// A save that outran its limit; the pick returns to the saved choice. -struct AidenBotChatActivityTimeout: Error {} /// 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 is +/// open it holds the newest, to report once when Advanced closes; a save that +/// lands after Advanced closed is reported at once, so it is never lost. +@MainActor +final class AidenBotChatActivityReport { + private var advancedOpen = true + private var unreported: AidenBotDetail? + + func saved(_ detail: AidenBotDetail, reportBehind: (AidenBotDetail) -> Void) { + if advancedOpen { + unreported = detail + } else { + reportBehind(detail) + } + } + + func advancedOpened() { advancedOpen = true } + + /// Advanced closed: what it held is reported once, and later saves go straight behind it. + func advancedClosed(reportBehind: (AidenBotDetail) -> Void) { + advancedOpen = false + if let detail = unreported { + 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 @@ -896,6 +963,8 @@ 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 @@ -935,6 +1004,7 @@ final class AidenBotChatActivityModel { guard next != value else { return } value = next errorMessage = nil + saveFailure = nil if isSaving { pending = next return @@ -943,31 +1013,7 @@ final class AidenBotChatActivityModel { defer { isSaving = false } var target = next while true { - do { - if let waitForConnection, await waitForConnection(connectionHold) == false { - throw AidenBotChatActivityUnreachable() - } - let save = save - let sending = target - let sentRevision = revision - let detail = try await Self.within(requestLimit) { try await save(sending, sentRevision) } - revision = detail.revision - saved = detail.chatActivity ?? target - onSaved(detail) - } catch { - pending = nil - if error is CancellationError { - value = saved - return - } - if aidenBotIsRevisionConflict(error) { - await adoptTheMacsChoice() - } - pending = nil - value = saved - errorMessage = AidenBotChatActivityCopy.saveFailed - return - } + guard await send(target) else { return } guard let following = pending else { break } pending = nil if following == saved { break } @@ -976,29 +1022,57 @@ final class AidenBotChatActivityModel { value = saved } - /// The Bot changed on the Mac: take its revision and choice, so the next pick can save. - private func adoptTheMacsChoice() async { - let refresh = refresh - guard let fresh = try? await Self.within(requestLimit, { try await refresh() }) else { return } + /// 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 { + if let waitForConnection, await waitForConnection(connectionHold) == false { + 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) } + revision = detail.revision + saved = detail.chatActivity ?? target + onSaved(detail) + return true + } catch { + pending = nil + if error is CancellationError { + value = saved + return false + } + let conflict = aidenBotIsRevisionConflict(error) + if conflict || aidenBotChatActivityTransportFailure(error), + let fresh = await adoptTheMacsChoice(), + (fresh.chatActivity ?? .standard) == target { + return true + } + 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 Mac may hold something else now: take its revision and choice, so the next pick can save. + private func adoptTheMacsChoice() async -> AidenBotDetail? { + let refresh = AidenBotMainActorCall(refresh) + guard let fresh = try? await aidenBotWithin(requestLimit, { try await refresh.call() }) else { return nil } revision = fresh.revision saved = fresh.chatActivity ?? .standard onSaved(fresh) - } - - nonisolated private static func within( - _ limit: Duration, - _ operation: @escaping @MainActor () async throws -> T - ) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { try await operation() } - group.addTask { - try await Task.sleep(for: limit) - throw AidenBotChatActivityTimeout() - } - defer { group.cancelAll() } - guard let first = try await group.next() else { throw AidenBotChatActivityTimeout() } - return first - } + return fresh } } @@ -1159,7 +1233,9 @@ struct AidenBotChatActivityPreviewView: View { } .padding(10) .background(palette.canvas, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .onScrollVisibilityChange(threshold: 0.05) { onScreen = $0 } + // A Form's rows come and go as they scroll, so appearing is being on screen. + .onAppear { onScreen = true } + .onDisappear { onScreen = false } .accessibilityHidden(true) } diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift index f2efc2c1a..bd3e1587e 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotGeneratedAvatarLifecycle.swift @@ -1024,9 +1024,20 @@ struct AidenBotCanonicalAvatarView: View { 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, @@ -1042,7 +1053,7 @@ struct AidenBotCanonicalAvatarView: View { name: name, size: size, isDecorative: isDecorative, - working: working + working: cue.glances ) if let canonicalImage { Image(uiImage: canonicalImage) @@ -1053,8 +1064,9 @@ struct AidenBotCanonicalAvatarView: View { .accessibilityHidden(isDecorative) .accessibilityLabel(isDecorative ? "" : "\(name) Bot photo") } - // A photo can't glance; a small glance mark says the Bot is working. - if working, canonicalImage != nil { + // 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()) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift index d6daee5d1..f93b4d945 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSemanticAvatarView.swift @@ -91,6 +91,25 @@ struct AidenBotFaceGlancePose: Equatable { 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) @@ -147,7 +166,8 @@ private struct AidenBotEyeMark: View { var glancing = false var body: some View { - TimelineView(.animation(paused: !glancing)) { context in + // 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() diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift index a2d4bf6c5..71e804e42 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift @@ -25,7 +25,17 @@ struct AidenBotSessionChatView: View { @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: AidenStreamInputMode = .queue + @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 } @@ -60,6 +70,10 @@ struct AidenBotSessionChatView: View { // 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()) @@ -121,7 +135,7 @@ struct AidenBotSessionChatView: View { } message: { Text(alertMessage ?? "Please try again.") } - .task(id: coordinator.activeInstanceId) { + .task(id: "\(coordinator.activeInstanceId ?? "")|\(startAttempt)") { await start() } .task(id: filesLookupKey) { @@ -132,9 +146,10 @@ struct AidenBotSessionChatView: View { if !showing { Task { await loadSummary() } } } .onChange(of: model?.isRunning == true) { _, running in - if !running { busyMode = .queue } + busyMode = aidenBotBusyMode(busyMode, isRunning: running) } - .onChange(of: coordinator.server?.supportsBotActivity == true) { _, supported in + // 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 @@ -279,7 +294,7 @@ struct AidenBotSessionChatView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 12) { if model.showsSkeleton { - AidenBotSessionSkeletonBubbles() + AidenBotSessionSkeletonBubbles(still: isUnreachable) } ForEach(AidenBotTranscriptRow.rows(for: model.entries)) { row in rowView(row, model: model) @@ -296,22 +311,30 @@ struct AidenBotSessionChatView: View { outgoingBubble(message, model: model) } AidenBotStatusLineView(model: model) - .id("status") 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) } - .onChange(of: model.queued.count) { _, _ in proxy.scrollTo("bottom", anchor: .bottom) } - .onChange(of: model.outgoing.count) { _, _ 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 { ScrollView { - AidenBotSessionSkeletonBubbles() + AidenBotSessionSkeletonBubbles(still: isUnreachable) .padding(.horizontal, 16) .padding(.vertical, 12) } @@ -319,6 +342,42 @@ struct AidenBotSessionChatView: View { } } + 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 private func rowView(_ row: AidenBotTranscriptRow, model: AidenBotSessionModel) -> some View { switch row { @@ -400,12 +459,12 @@ struct AidenBotSessionChatView: View { @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(palette.foreground) + .foregroundStyle(faded ? Color(aidenHex: aidenBotPendingTextHex(palette)) : palette.foreground) .padding(.horizontal, 14) .padding(.vertical, 10) .background(palette.raised, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) - .opacity(faded ? 0.55 : 1) caption() .font(.caption) .foregroundStyle(palette.secondary) @@ -452,21 +511,25 @@ struct AidenBotSessionChatView: View { .foregroundStyle(palette.danger) Text("·") .accessibilityHidden(true) - Button(AidenBotSessionCopy.tryAgain) { + 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()) } - .font(.caption.weight(.semibold)) - .foregroundStyle(palette.accent) .disabled(!canCompose) Button { model.discardSend(message.id) } label: { Image(systemName: "xmark") .font(.caption.weight(.semibold)) - .frame(width: 28, height: 28) + .frame(width: 44, height: 44) .contentShape(Rectangle()) } - .accessibilityLabel("Remove message") + .accessibilityLabel(AidenBotSessionCopy.removeMessage) } .buttonStyle(.plain) } @@ -554,6 +617,8 @@ struct AidenBotSessionChatView: View { Button(AidenBotSessionCopy.tryAgain) { Task { await model.load() } } .font(.footnote.weight(.semibold)) .foregroundStyle(palette.accent) + .frame(minHeight: 44) + .contentShape(Rectangle()) .buttonStyle(.plain) } } @@ -570,17 +635,22 @@ struct AidenBotSessionChatView: View { /// 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. + /// 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 enabled = canCompose && model.canSend && !draftIsEmpty + let available = canCompose && model.canSend + let enabled = available && !draftIsEmpty return Menu { ForEach(AidenStreamInputMode.busyModes, id: \.self) { mode in Button { @@ -599,7 +669,7 @@ struct AidenBotSessionChatView: View { Text(busyMode.busyLabel) .font(.subheadline.weight(.semibold)) .padding(.horizontal, 14) - .frame(minHeight: 40) + .frame(minHeight: max(44, controlSize)) .foregroundStyle(enabled ? palette.onAccent : palette.secondary) .background(enabled ? palette.accent : palette.raised, in: Capsule()) } primaryAction: { @@ -607,56 +677,79 @@ struct AidenBotSessionChatView: View { } .menuStyle(.button) .buttonStyle(.plain) - .disabled(!enabled) + .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 { - 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(!canCompose) - if model.isRunning { - if model.offersBusyModes { - busyPill(model) - } - Button { - Task { await model.stop() } - } label: { - Image(systemName: "stop.fill") - .font(.body.weight(.semibold)) - .frame(width: 40, height: 40) - .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: 40, height: 40) - .background(palette.accent, in: Circle()) - } - .buttonStyle(.plain) - .disabled(!canCompose || !model.canSend || draftIsEmpty) - .accessibilityLabel("Send") + 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 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( @@ -664,15 +757,16 @@ struct AidenBotSessionChatView: View { transport: client, cacheKey: AidenBotSessionKey(instanceId: context.instanceId, deviceId: context.deviceId, botID: botID) ) - model.activitySupported = coordinator.server?.supportsBotActivity == true + 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() async let firstLoad: Void = model.load() await model.listen() - _ = await (summaryLoad, firstLoad) + // Leaving stops the sending at once; the reads still finishing don't hold it up. model.release() + _ = await (summaryLoad, firstLoad) } @MainActor @@ -703,9 +797,51 @@ func aidenBotSessionConversationChatID( extension AidenBotSessionCopy { static let sending = "Sending…" - static let couldNotSend = "Couldn't send" + 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). @@ -730,15 +866,18 @@ func aidenBotConnectionHint(for model: AidenBotSessionModel, nowMs: Int) -> Aide /// 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: reduceMotion) - AidenBotSkeletonBlock(width: 252, height: 70, radius: 20, reduceMotion: reduceMotion) + 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: reduceMotion) + AidenBotSkeletonBlock(width: 164, height: 48, radius: 20, reduceMotion: quiet) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -754,12 +893,28 @@ struct AidenBotStatusLineView: View { @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: 18, color: palette.secondary) + AidenActivityMark(mark: line.display.mark, size: markSize, color: palette.secondary) Text(line.display.word) if let elapsed = line.elapsed { Text("· \(elapsed)").monospacedDigit() diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index 52e077015..fe1e97ab8 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -27,6 +27,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`). @@ -219,6 +234,9 @@ 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) } @@ -245,7 +263,7 @@ struct AidenBotOutgoingMessage: Equatable, Identifiable, Sendable { 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". + /// It didn't reach the Mac: "Couldn’t send · Try again". case failed } @@ -283,13 +301,17 @@ struct AidenBotOutgoingMessage: Equatable, Identifiable, Sendable { 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. + /// 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 @@ -321,7 +343,7 @@ enum AidenBotOutgoingLook: Equatable { case sent /// The Bot is working: Queued, as Steer or Queue. case queued(AidenStreamInputMode) - /// "Couldn't send · Try again". + /// "Couldn’t send · Try again". case failed } @@ -381,7 +403,7 @@ final class AidenBotSessionCache { /// 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. +/// back as "Couldn’t send · Try again", never silently gone. actor AidenBotOutboxStore { static let shared = AidenBotOutboxStore() static let lifetime: TimeInterval = 24 * 60 * 60 @@ -498,13 +520,11 @@ actor AidenBotOutboxStore { } } - /// The chat's messages, oldest first. One still unsent after 24 hours comes - /// back as not sent; a taken one that old is the Mac's now. - func load(_ key: AidenBotSessionKey) -> [AidenBotOutgoingMessage] { restore(key).messages } - - /// The chat's messages, and whether some couldn't be read back. 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. + /// 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) -> (messages: [AidenBotOutgoingMessage], unreadable: Bool) { knownDevices[key.instanceId, default: []].insert(key.deviceId) let url = fileURL(key) @@ -886,8 +906,10 @@ final class AidenBotSessionModel { private enum Intent { case none, requested, acknowledged } - /// Tries a message gets on a connection that keeps failing before it says "Couldn't send". + /// 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 /// `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 @@ -926,6 +948,7 @@ final class AidenBotSessionModel { message.deferred = false message.dropEcho() message.queuedID = nil + message.unheldSince = nil if message.status == .sending, isExpired(message, now: now) { message.expire() } return message } @@ -999,9 +1022,12 @@ final class AidenBotSessionModel { /// 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 activitySupported, isRunning || isAwaitingTurn else { return nil } + 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) } @@ -1215,8 +1241,11 @@ final class AidenBotSessionModel { /// 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 snapshot also - /// drops a taken message that is neither queued nor echoed. + /// 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 @@ -1243,9 +1272,14 @@ final class AidenBotSessionModel { if let id = message.submissionId { if queuedIDs.contains(id) { message.queuedID = id - } else if snapshot, activitySupported { - // Taken, not queued and not in the transcript: nothing on the Mac holds it. - continue + 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 }) { @@ -1439,7 +1473,7 @@ final class AidenBotSessionModel { 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. + // it says "Couldn’t send", and Try again keeps the key. releaseEcho(at: current) outbox[current].status = .failed } diff --git a/ios/AidenOnTheGo/Models/AidenInstallation.swift b/ios/AidenOnTheGo/Models/AidenInstallation.swift index dd1e8c59a..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 { @@ -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 c5c649f4b..1cf6d8e69 100644 --- a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift +++ b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift @@ -306,10 +306,15 @@ struct AidenServer: Codable, Equatable, Sendable { 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 { - features.contains(Self.botActivityFeature) && capabilities.contains(.botActivity) + announcesBotActivity && capabilities.contains(.botActivity) } /// This device both may and did negotiate foreign-run observation. diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index 62fc6a61b..b03b8e80b 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -1,3 +1,4 @@ +import SwiftUI import XCTest @testable import AidenOnTheGo @@ -1628,6 +1629,39 @@ final class AidenBotActivityContractTests: XCTestCase { } } + /// 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"])) @@ -1937,6 +1971,36 @@ final class AidenBotSessionActivityTests: XCTestCase { 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() @@ -2216,6 +2280,71 @@ final class AidenBotSessionOptimisticTests: XCTestCase { 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)) @@ -3363,6 +3492,20 @@ final class AidenBotSessionOptimisticTests: XCTestCase { /// 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. @@ -3461,6 +3604,18 @@ final class AidenBotChatActivityTests: XCTestCase { 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)) + } + } + /// A model whose Mac accepts every save, with the calls it saw. private func model( revision: String = "rev_1", @@ -3529,7 +3684,8 @@ final class AidenBotChatActivityTests: XCTestCase { await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) XCTAssertEqual(model.value, macsChoice, "the radio shows what the Mac has now") - XCTAssertEqual(model.errorMessage, "Aiden couldn’t save that choice.") + 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) @@ -3542,7 +3698,7 @@ final class AidenBotChatActivityTests: XCTestCase { 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.errorMessage, "Aiden couldn’t save that choice.") + XCTAssertEqual(model.saveFailure, .conflict) } func testAPickShowsAtOnceAndASecondTapWhileSavingIsNeitherLostNorSentOnAStaleRevision() async throws { @@ -3555,7 +3711,7 @@ final class AidenBotChatActivityTests: XCTestCase { }) let first = Task { await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) } - while release == nil { await Task.yield() } + try await untilSaving { release != nil } XCTAssertEqual(model.value.level, .summary, "the choice shows before the Mac answers") XCTAssertTrue(model.isSaving) @@ -3580,7 +3736,7 @@ final class AidenBotChatActivityTests: XCTestCase { return try self.detail(revision: "rev_\(calls.count + 1)", next) }) let first = Task { await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) } - while release == nil { await Task.yield() } + try await untilSaving { release != nil } await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) await model.pick(AidenBotChatActivity(level: .detailed, wording: .thinking)) await model.pick(AidenBotChatActivity(level: .quiet, wording: .thinking)) @@ -3603,7 +3759,7 @@ final class AidenBotChatActivityTests: XCTestCase { return try self.detail(revision: "rev_\(calls.count + 1)", next) }) let first = Task { await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) } - while release == nil { await Task.yield() } + try await untilSaving { release != nil } await model.pick(original) XCTAssertEqual(model.value, original, "the page already shows the choice they went back to") release?.resume() @@ -3647,7 +3803,8 @@ final class AidenBotChatActivityTests: XCTestCase { await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) XCTAssertEqual(calls, 0) XCTAssertEqual(model.value, .standard) - XCTAssertEqual(model.errorMessage, "Aiden couldn’t save that choice.") + 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 { @@ -3662,7 +3819,7 @@ final class AidenBotChatActivityTests: XCTestCase { 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.errorMessage, "Aiden couldn’t save that choice.") + XCTAssertEqual(model.saveFailure, .offline) hang = false await model.pick(AidenBotChatActivity(level: .summary, wording: .follows)) @@ -3735,11 +3892,72 @@ final class AidenBotChatActivityTests: XCTestCase { ) } let announced = [AidenServer.botActivityFeature] - XCTAssertTrue(aidenBotShowsChatActivity(server: server(features: announced, grants: [.botActivity]), hasBaseline: true)) - XCTAssertFalse(aidenBotShowsChatActivity(server: server(features: [], grants: [.botActivity]), hasBaseline: true), "an older Mac") - XCTAssertFalse(aidenBotShowsChatActivity(server: server(features: announced, grants: []), hasBaseline: true), "no grant") - XCTAssertFalse(aidenBotShowsChatActivity(server: nil, hasBaseline: true), "no server read yet") - XCTAssertFalse(aidenBotShowsChatActivity(server: server(features: announced, grants: [.botActivity]), hasBaseline: false), "no Bot detail yet") + 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 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.advancedClosed(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.advancedClosed(reportBehind: { behind.append($0.revision) }) + XCTAssertEqual(behind, ["rev_3", "rev_4"]) + } + + func testTheRunningStepReadsInTheMacsPhaseWords() { + let running = (1...3).compactMap { AidenBotChatActivityPreview.frame(index: $0, level: .detailed).running } + XCTAssertEqual(running, ["Reading", "Searching the web", "Editing files"]) } } @@ -3756,3 +3974,54 @@ private actor LiveRequests { 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) -> [AidenBotOutgoingMessage] { restore(key).messages } +} diff --git a/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift b/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift index e227f72a9..5d803baa6 100644 --- a/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift +++ b/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift @@ -3590,6 +3590,60 @@ final class AidenRemoteClientTests: XCTestCase { 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() From dc7a1bf96320e0cc658d8eb39619f5a8fd675241 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 18:04:44 -0400 Subject: [PATCH 36/83] Bots: one fail-closed queued classifier for desktop and phones; queued list by inclusion createQueuedInputClassifier is the classify-once, fail-closed, throttled retry that the desktop feed and the Remote projector each carried a copy of; both now use it. liveQueuedMessages lists only ids classified as the person's own (an inclusion filter), so an unclassified, routine or self-intro input can't reach a list by construction. The Resume baseline is serialized once when the Resume is heard instead of on every derivation. Tests: a reconnect snapshot mid-run carries the phase and the queued list; the app phase keeps a 40-code-point name and other phases drop it; a frame that leaves the phase unchanged sends no activity frame; the Stop-with-nothing-running check no longer waits on a fixed 50 ms. Co-Authored-By: Claude Opus 5.5 --- .../services/aiden-remote-bot-session.test.ts | 81 +++++++- main/services/aiden-remote-bot-session.ts | 84 +++----- .../bot-runtime/live-projection.test.ts | 9 +- main/services/bot-runtime/live-projection.ts | 191 +++++++++++------- 4 files changed, 234 insertions(+), 131 deletions(-) diff --git a/main/services/aiden-remote-bot-session.test.ts b/main/services/aiden-remote-bot-session.test.ts index 560ac47fb..4c56a38cb 100644 --- a/main/services/aiden-remote-bot-session.test.ts +++ b/main/services/aiden-remote-bot-session.test.ts @@ -16,6 +16,7 @@ import { AidenRemoteBotService } from "./aiden-remote-bots.js"; import { AidenRemoteBotSessionService, projectAidenRemoteBotMemory, + projectBotSessionActivity, projectBotSessionEntries, projectBotSessionState, redactRemoteError, @@ -1450,6 +1451,75 @@ test("a device without bot:activity gets the state it already has at each activi } }); +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" }); @@ -1472,10 +1542,11 @@ test("a Stop pressed anywhere reads Stopping on phones until the run ends, and a 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 - await service.session(BOT_ID, { activity: true }); // runs after the intent on the projector's lane - await new Promise((resolve) => setTimeout(resolve, 50)); - assert.equal(stream.frames.length, settled, "nothing was running, so nothing changed"); + 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( @@ -1483,7 +1554,9 @@ test("a Stop pressed anywhere reads Stopping on phones until the run ends, and a { "pi.live": { run: { inputs: [3] } } }, ); await stream.until(() => last(phasesOf(stream.frames)) !== null, "the next run"); - assert.equal(last(phasesOf(stream.frames)), "preparing", "the next turn starts at Getting ready"); + // 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(); diff --git a/main/services/aiden-remote-bot-session.ts b/main/services/aiden-remote-bot-session.ts index 1c904ed6e..50f62f911 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -105,12 +105,13 @@ import { stopWithIntents, type BotTurnIntent, type BotTurnIntents } from "./bot- import { liveBotActivity, livePartialText, - liveQueuedInputIds, liveQueuedMessages, liveRunKey, createBotRunStarts, + createQueuedInputClassifier, projectBotTranscript, type BotRunStarts, + type QueuedInputClassifier, } from "./bot-runtime/live-projection.js"; import { botRoutineLabel, @@ -444,10 +445,9 @@ export function projectBotSessionQueued( docs: Readonly>, personal: ReadonlySet, ): AidenRemoteBotQueuedMessage[] { - const quiet = new Set(liveQueuedInputIds(docs).filter((id) => !personal.has(id))); const seen = new Set(); const queued: AidenRemoteBotQueuedMessage[] = []; - for (const message of liveQueuedMessages(docs, quiet)) { + 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); @@ -538,16 +538,12 @@ class BotLiveProjector { private stopRequested = false; /** This run is a Resume and has not moved on yet. */ private resuming = false; - /** `pi.live` when the Resume was heard (`BotLiveActivityInput.resumedFrom`). */ - private resumedFrom: unknown; + /** `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; - /** Queued submission ids classified as quiet (true) or the person's own (false). */ - private readonly inputKinds = new Map(); - private classifyFailing = false; - /** No classification before this time: a persistent failure is not retried on every frame. */ - private classifyNotBefore = 0; - private classifyRetry: ReturnType | undefined; + /** 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, @@ -570,7 +566,22 @@ class BotLiveProjector { 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); @@ -747,7 +758,7 @@ class BotLiveProjector { if (this.resumeRequested) { this.resumeRequested = false; this.resuming = true; - this.resumedFrom = docs["pi.live"]; + this.resumedFrom = JSON.stringify(docs["pi.live"]); } if (this.stopRequested) this.stopping = true; } else { @@ -783,33 +794,12 @@ class BotLiveProjector { } /** - * The person's queued messages (the desktop's `queuedFor`). Each id is - * classified once; 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. A failed - * or incomplete classification is retried after a pause, not on every frame. + * 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 { - const deps = this.activityDeps!; - const ids = liveQueuedInputIds(docs); - for (const id of [...this.inputKinds.keys()]) if (!ids.includes(id)) this.inputKinds.delete(id); - const unknown = ids.filter((id) => !this.inputKinds.has(id)); - if (unknown.length > 0 && deps.now() >= this.classifyNotBefore) { - try { - const answers = await deps.classify(unknown); - for (const id of unknown) { - const quiet = answers.get(id); - if (quiet !== undefined) this.inputKinds.set(id, quiet); - } - this.classifyFailing = false; - } catch (error) { - if (!this.classifyFailing) deps.report(error); - this.classifyFailing = true; - } - if (unknown.some((id) => !this.inputKinds.has(id))) this.scheduleClassifyRetry(); - } else if (unknown.length > 0) { - this.scheduleClassifyRetry(); - } - return projectBotSessionQueued(docs, new Set(ids.filter((id) => this.inputKinds.get(id) === false))); + return projectBotSessionQueued(docs, await this.classifier!.personal(docs)); } /** @@ -854,22 +844,6 @@ class BotLiveProjector { } } - /** One retry after the pause, on the serial lane, even if no frame arrives. */ - private scheduleClassifyRetry(): void { - const { retryMs, now } = this.activityDeps!; - this.classifyNotBefore = Math.max(this.classifyNotBefore, now() + retryMs); - if (this.classifyRetry !== undefined || this.closed) return; - this.classifyRetry = setTimeout(() => { - this.classifyRetry = undefined; - void this.serial(async () => { - if (this.closed) return; - this.classifyNotBefore = 0; - if (this.view) await this.publish(this.view); - }); - }, retryMs); - this.classifyRetry.unref?.(); - } - private async publish(view: ConversationView): Promise { if (this.closed) return; const next = await this.resolveEntries(projectBotSessionEntries(view.entries, await this.submissionsFor(view.entries))); @@ -963,7 +937,7 @@ class BotLiveProjector { if (this.closed) return; this.closed = true; clearTimeout(this.idleTimer); - clearTimeout(this.classifyRetry); + this.classifier?.close(); this.frame({ type: "closed", payload: {} }); this.onClosed(); await this.stopWatch?.().catch(() => undefined); diff --git a/main/services/bot-runtime/live-projection.test.ts b/main/services/bot-runtime/live-projection.test.ts index 63039116b..474d91c0a 100644 --- a/main/services/bot-runtime/live-projection.test.ts +++ b/main/services/bot-runtime/live-projection.test.ts @@ -849,7 +849,7 @@ test("the live phase reads stop, a waiting card, compaction, the newest running 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: paused["pi.live"] }; + 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"); @@ -1288,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 c2e851035..8d7deeed8 100644 --- a/main/services/bot-runtime/live-projection.ts +++ b/main/services/bot-runtime/live-projection.ts @@ -485,11 +485,12 @@ export interface BotLiveActivityInput { /** This run is a Resume and has not moved on yet. */ resuming: boolean; /** - * `pi.live` as it was when the Resume began. 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. + * `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?: unknown; + resumedFrom?: string; startedAt: number; } @@ -529,7 +530,7 @@ export function liveBotActivity(input: BotLiveActivityInput): BotLiveActivity | 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 && JSON.stringify(input.resumedFrom) === JSON.stringify(live)) { + 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"); @@ -564,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 []; @@ -581,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 */ /* ------------------------------------------------------------------------- */ @@ -679,19 +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 (`BotLiveActivityInput.resumedFrom`). */ - resumedFrom: unknown; + /** `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; @@ -828,7 +905,7 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro feed.startedAt = runStarts.startedAt(feed.botId, liveRunKey(feed.frame.docs), now); feed.stopping = false; feed.resuming = resumingBots.delete(feed.botId); - feed.resumedFrom = feed.frame.docs["pi.live"]; + feed.resumedFrom = feed.resuming ? JSON.stringify(feed.frame.docs["pi.live"]) : undefined; } async function resolveCards(botId: string, entries: BotTranscriptEntry[]): Promise { @@ -859,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>) { @@ -978,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(), @@ -988,10 +1044,7 @@ 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, @@ -1004,6 +1057,7 @@ 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); @@ -1046,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(); From 2161fd1b2c2d4d1ba09d5682a7ed1cd40c32819a Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 18:05:17 -0400 Subject: [PATCH 37/83] Remote router test: a held bot:activity grant gets activity only while both halves are wired For each wiring row, a phone that already holds bot:activity is served activity on its session read and stream only when the session service and the Bot service are both wired for Chat activity. Co-Authored-By: Claude Opus 5.5 --- main/services/aiden-remote-router.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/main/services/aiden-remote-router.test.ts b/main/services/aiden-remote-router.test.ts index 48718c283..d9b4a2a50 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -5128,6 +5128,25 @@ test("bot-activity-v1 and bot:activity are offered only while activity frames an } 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(); + } } }); From a49457765e475c83aa638456be8120f7c11b9435 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 18:07:40 -0400 Subject: [PATCH 38/83] Remote Bots: a failing placement lookup is reported once per streak; memo bound is tested The submissionId lookup for a new message now reports a failing streak once, as the classification already did and as the onReport doc says. A test seam (submissionTagsHeld) shows the bubble-tag memo holding exactly the session window after more messages than the window. Co-Authored-By: Claude Opus 5.5 --- .../services/aiden-remote-bot-session.test.ts | 18 ++++++++++---- main/services/aiden-remote-bot-session.ts | 24 ++++++++++++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/main/services/aiden-remote-bot-session.test.ts b/main/services/aiden-remote-bot-session.test.ts index 4c56a38cb..8a0f51c36 100644 --- a/main/services/aiden-remote-bot-session.test.ts +++ b/main/services/aiden-remote-bot-session.test.ts @@ -2041,6 +2041,8 @@ test("tags stay on every bubble in view, past the window, and no old bubble is e 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(); @@ -2061,7 +2063,7 @@ test("a failed placement lookup is reported, and that bubble falls back to its t ...fake.runtime, async placedSubmissions() { if (failing) throw new Error("The session could not be inspected."); - return new Map([["2", "sub_2"]]); + return new Map([["3", "sub_3"]]); }, }, }, @@ -2070,13 +2072,21 @@ test("a failed placement lookup is reported, and that bubble falls back to its t 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 lunches")]); + 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 lunches", "sub_2"], + ["And snacks", null], + ["And lunches", "sub_3"], ]); - assert.deepEqual(reports.map((error) => (error as Error).message), ["The session could not be inspected."]); + 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(); } diff --git a/main/services/aiden-remote-bot-session.ts b/main/services/aiden-remote-bot-session.ts index 50f62f911..b07daf121 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -184,8 +184,8 @@ export interface AidenRemoteBotSessionServiceOptions { turnIntents?: Pick; /** * A queued input could not be classified (it stays off phones and is - * retried; reported once per failing streak), or the submission a new - * message was placed as could not be read (that bubble goes without its id). + * 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. */ @@ -554,6 +554,8 @@ class BotLiveProjector { 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( @@ -820,9 +822,11 @@ class BotLiveProjector { 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. - this.activityDeps.report(error); + if (!this.placedFailing) this.activityDeps.report(error); + this.placedFailing = true; return this.submissionOf; } for (const [entryId, submissionId] of placed) { @@ -831,6 +835,11 @@ class BotLiveProjector { 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; @@ -1438,6 +1447,15 @@ 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); From 04b623430ea131c161fd2a5107eaafef75907b5a Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 18:09:59 -0400 Subject: [PATCH 39/83] iOS Bots: New Bot, Profile, Memory and Advanced apply at once (R15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Bot opens with its questions at once; the AI model row is a skeleton until the Mac's models arrive (cache first) and Create works while connecting. Create closes the sheet: Bots shows the new Bot as "Creating…", the create waits briefly for the Mac and goes out, and a failure takes the row away with Try Again, which reopens New Bot as it was. The same request keeps its Idempotency-Key, and a Bot already made is kept, so a retry never makes two. - Profile edits (name, subtitle, character, instructions) show at once, are editable from the saved details offline, go to the Mac in order on the newest revision after a short wait for the connection, settle a lost answer by re-reading, and roll back to the Mac's Bot with an error. The instructions editor closes as it saves; the photo upload shows a shimmer instead of a spinner. - Memory edits, deletes and Erase show at once and go in order (none is turned away mid-flight); a failure refetches the view or puts back what showed before. The edit sheet closes as it saves, and loading is a skeleton instead of a spinner. - Advanced edits the saved settings while connecting; a connection coming up re-reads and rebases the edits instead of wiping them. Save closes the page at once and the save runs after it; the screen behind hears the Mac's Bot, or why it didn't stick. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotAdvancedView.swift | 311 +++++---- .../Features/Bots/AidenBotCreateView.swift | 606 +++++++++++------- .../Features/Bots/AidenBotMemoryView.swift | 125 ++-- .../Features/Bots/AidenBotProfileView.swift | 286 ++++++--- .../Bots/AidenBotSessionChatView.swift | 19 +- .../Features/Bots/AidenBotSettingsDraft.swift | 12 + .../Features/Bots/AidenBotsHomeView.swift | 79 ++- ios/AidenOnTheGo/Models/AidenBotMemory.swift | 70 ++ .../AidenBotHomeProfileTests.swift | 280 ++++++++ .../AidenBotMemoryTests.swift | 95 ++- 10 files changed, 1356 insertions(+), 527 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift index e82c48a23..7c821645a 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,8 +98,6 @@ 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 @@ -38,8 +108,6 @@ struct AidenBotAdvancedView: View { AidenBotCustomAccessSessionIdentity(coordinator: coordinator) } - private var isSaving: Bool { activeAttempt != nil } - private var isDirty: Bool { aidenBotEditorIsDirty( draft: draft, @@ -50,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 } @@ -66,43 +134,35 @@ 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) } + .onChange(of: coordinator.connectionState == .connected) { _, connected in + guard connected, baselineBot != nil else { return } + let expectedSession = sessionIdentity + Task { await load(for: expectedSession) } + } .onAppear { chatActivityReport.advancedOpened() } .onDisappear { // Closing Advanced (not opening Chat activity) reports a saved pick once; // one that lands later is reported when it lands. if !isShowingChatActivity { chatActivityReport.advancedClosed(reportBehind: onSaved) } } - .alert( - "Couldn’t Save", - isPresented: Binding( - get: { saveError != nil }, - set: { if !$0 { saveError = nil } } - ) - ) { - Button("OK", role: .cancel) { saveError = nil } - } message: { - Text(saveError ?? "Your changes weren’t saved.") - } .confirmationDialog( "Discard changes?", isPresented: $isConfirmingDiscard, @@ -144,8 +204,8 @@ struct AidenBotAdvancedView: View { } } - /// Editing waits for a connection and for a save; Chat activity has its own rules. - private var isLocked: Bool { isSaving || !canWrite } + /// 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 { @@ -154,6 +214,13 @@ 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).disabled(isLocked) accessSection(catalog).disabled(isLocked) @@ -260,7 +327,7 @@ struct AidenBotAdvancedView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(isDirty || isSaving) + .disabled(isDirty) } footer: { Text(isDirty ? "Save or cancel your changes first." : "What you see in its chat on your Mac while it works.") } @@ -424,7 +491,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." } @@ -439,8 +506,6 @@ struct AidenBotAdvancedView: View { capturedContext = nil isLoading = true loadError = nil - saveError = nil - activeAttempt = nil isConfirmingDiscard = false chatActivityModel = nil isShowingChatActivity = false @@ -449,37 +514,51 @@ struct AidenBotAdvancedView: View { @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 @@ -496,7 +575,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 { @@ -504,104 +583,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 } - chatActivityReport.reportedByAdvanced() - 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 { - chatActivityReport.reportedByAdvanced() - 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() } } diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift index 83d6cc71d..0ed8a3cec 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift @@ -1,329 +1,441 @@ +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: Equatable { + var name: String + var purpose: String + /// The model the person picked; nil uses the Mac's recommended one. + var draft: AidenBotEditorDraft? +} + +/// The requests one Bot create makes; the remote client provides them, tests a fake. +struct AidenBotCreateOperations { + /// The pairing the create is for, so a retry matches only its own attempt. + var scope: 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 } +} + +/// 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. +@MainActor +@Observable +final class AidenBotCreateFlow { + struct Pending: Equatable, Identifiable { + let id: UUID + let name: String + let purpose: String + } + + struct Failure: Equatable { + let message: String + let input: AidenBotCreateInput + } + + static let unreachable = "Aiden couldn’t reach your Mac, so the Bot wasn’t made. Try again." + + private(set) var pending: Pending? + var failure: Failure? + + private struct Attempt { + let scope: String + let request: AidenBotCreateRequest + let key: UUID + let chatKey: UUID + var created: AidenBotDetail? + } + + @ObservationIgnored private var retained: Attempt? + @ObservationIgnored private let connectionHold: Duration + @ObservationIgnored private let requestLimit: Duration + + init(connectionHold: Duration = .seconds(8), requestLimit: Duration = .seconds(12)) { + self.connectionHold = connectionHold + self.requestLimit = requestLimit + } + + /// Makes the Bot; returns once it exists on the Mac (and `onCreated` ran) or failed. + func submit( + _ input: AidenBotCreateInput, + operations: AidenBotCreateOperations, + onCreated: @MainActor (AidenBotDetail) async -> Void + ) async { + guard pending == nil else { return } + failure = nil + pending = Pending( + id: UUID(), + name: input.name.trimmingCharacters(in: .whitespacesAndNewlines), + purpose: input.purpose.trimmingCharacters(in: .whitespacesAndNewlines) + ) + defer { pending = nil } + var sent = false + do { + guard await operations.waitForConnection(connectionHold) else { + failure = Failure(message: Self.unreachable, input: input) + return + } + guard operations.isCurrent() else { return } + let (request, draft) = try await resolve(input, operations: operations) + var attempt: Attempt + if let retained, retained.scope == operations.scope, retained.request == request { + attempt = retained + } else { + attempt = Attempt(scope: operations.scope, request: request, key: UUID(), chatKey: UUID()) + } + retained = attempt + sent = true + let created: AidenBotDetail + if let made = attempt.created { + created = made + } else { + created = try await bounded { try await operations.create(attempt.request, attempt.key) } + attempt.created = created + retained = attempt + } + guard operations.isCurrent() else { return } + if !operations.usesDurableSession, let draft { + let chat = try AidenBotChatCreateRequest( + providerId: draft.customAccess.providerID, + modelId: draft.customAccess.modelID + ) + try await bounded { try await operations.createChat(created.id, chat, attempt.chatKey) } + guard operations.isCurrent() else { return } + } + let authoritative = try await bounded { try await operations.readBot(created.id) } + guard operations.isCurrent() else { return } + retained = nil + await onCreated(authoritative) + } catch is CancellationError { + return + } catch { + if await operations.handleRevocation(error) { + retained = nil + return + } + guard operations.isCurrent() else { return } + // A Bot already made, or a request that may have arrived, is tried again as it was. + if sent, retained?.created == nil, !aidenBotEditorCreateFailureIsAmbiguous(error) { + retained = nil + } + failure = Failure(message: aidenBotFriendlyLoadError(error), input: input) + } + } + + /// 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) + } + + 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/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/AidenBotSessionChatView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift index 71e804e42..c7403381d 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift @@ -20,6 +20,8 @@ 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? @@ -119,10 +121,16 @@ 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", @@ -162,11 +170,12 @@ struct AidenBotSessionChatView: View { /// 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 ?? model?.restoreNotice ?? (model?.hasLoaded == true ? model?.errorMessage : nil) + deleteErrorMessage ?? advancedSaveError ?? model?.restoreNotice ?? (model?.hasLoaded == true ? model?.errorMessage : nil) } private func clearErrors() { deleteErrorMessage = 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 } diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSettingsDraft.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSettingsDraft.swift index b465a0ad4..eed812de0 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSettingsDraft.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSettingsDraft.swift @@ -22,6 +22,18 @@ 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 { diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift index 7a568a3b1..aca3cfda1 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() + /// What New Bot reopens with after a failed create. + @State private var createPrefill: AidenBotCreateInput? @FocusState private var searchIsFocused: Bool private var loadID: AidenBotsHomeLoadID { @@ -342,10 +346,11 @@ struct AidenBotsHomeView: View { ) } + /// 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 { @@ -379,11 +384,8 @@ struct AidenBotsHomeView: View { .sheet(item: $presentedSheet) { 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 in + startCreate(input) } case let .profile(bot): AidenBotProfileView( @@ -416,6 +418,26 @@ struct AidenBotsHomeView: View { ) { if let bot = pendingDelete { Task { await delete(bot) } } } + .alert( + AidenBotCreateCopy.failedTitle, + isPresented: Binding( + get: { createFlow.failure != nil }, + set: { if !$0 { createFlow.failure = nil } } + ), + presenting: createFlow.failure + ) { failure in + Button("Try Again") { + createPrefill = failure.input + createFlow.failure = nil + presentedSheet = .create + } + Button("OK", role: .cancel) { + createPrefill = nil + createFlow.failure = nil + } + } message: { failure in + Text(failure.message) + } .alert( "Couldn’t Delete", isPresented: Binding( @@ -477,6 +499,7 @@ struct AidenBotsHomeView: View { } .disabled(allRows.isEmpty && !isSearching) headerButton(systemImage: "plus", label: "New Bot") { + createPrefill = nil presentedSheet = .create } .disabled(!canCreateBot) @@ -527,16 +550,24 @@ struct AidenBotsHomeView: View { @ViewBuilder private var content: some View { + if let pending = createFlow.pending { + AidenBotPendingCreateRow(pending: pending) + } switch contentState { case .loading: AidenBotHomeSkeletonView(reduceMotion: reduceMotion) + case .empty where createFlow.pending != 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 +582,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) @@ -663,6 +697,33 @@ struct AidenBotsHomeView: View { .padding(.vertical, 10) } + /// Makes the Bot New Bot asked for, after the sheet closed (R15). + @MainActor + private func startCreate(_ input: AidenBotCreateInput) { + guard let context = try? coordinator.requestContext() else { return } + createPrefill = nil + let coordinator = coordinator + let operations = AidenBotCreateOperations( + scope: "\(context.instanceId)|\(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) { 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/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/AidenOnTheGoTests/AidenBotHomeProfileTests.swift b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift index 0a39827e4..5ac4ea4c2 100644 --- a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift @@ -469,3 +469,283 @@ final class AidenBotHomeProfileTests: XCTestCase { XCTAssertThrowsError(try draft.accessUpdate(catalog: catalog)) } } + +/// 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") throws -> AidenBotDetail { + var object = try XCTUnwrap(try shared()["botDetail"] as? [String: Any]) + object["id"] = id + object["revision"] = revision + object["name"] = name + 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)) + } + + private func catalog() throws -> AidenBotCapabilityCatalog { + try AidenRemoteJSONDecoder.decode( + AidenBotCapabilityCatalog.self, + from: JSONSerialization.data(withJSONObject: try XCTUnwrap(try shared()["botCapabilityCatalog"])) + ) + } + + 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 + } + + private func createOperations( + _ mac: CreateMac, + connected: Bool = true, + durable: Bool = true + ) throws -> AidenBotCreateOperations { + let catalog = try catalog() + return AidenBotCreateOperations( + scope: "mac_a|device_1", + usesDurableSession: durable, + waitForConnection: { _ in connected }, + catalog: { catalog }, + create: { request, key in + mac.creates.append((request, key)) + 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) + + func testCreateShowsTheBotAtOnceAndOpensItOnceTheMacHasIt() async throws { + let mac = CreateMac() + mac.holds = true + let flow = AidenBotCreateFlow() + 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 = AidenBotCreateFlow() + 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 testABotMadeBeforeItsChatFailedIsNotMadeAgain() async throws { + let mac = CreateMac() + mac.chatErrors = [URLError(.timedOut)] + let flow = AidenBotCreateFlow() + 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) + } + + func testACreateThatNeverReachesTheMacSaysSoAndSendsNothing() async throws { + let mac = CreateMac() + let flow = AidenBotCreateFlow() + 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")), From 7533f2096c40f14bc7541648c0ecc276159a65cf Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 18:10:53 -0400 Subject: [PATCH 40/83] Android Bots: persisted activity support, pairing-pinned saves, chat polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FR-m3: bot:activity is asked for only when the Mac's capability list offers it (the iOS rule); support still needs the announced token. - R21: the feature tokens /server announced are persisted with the grant, so a cold offline start keeps the line, Steer/Queue, whenBusy and the Chat activity row only when both were last seen; a frozen send replays. - Chat activity: detached work, pairing memos and the Chat activity host are pinned to the pairing (instance, device, paired-at), not the Mac's id, so an unpair and re-pair never inherit a grant, host or late save; the host lets go of a closed page's listener once its save reported; Advanced's own Save writes through the pinned cache; the Session's first read and Advanced's fallback read are authority-checked. - FR-m8: the status line's state is its elapsed time; the word is announced on its own, at most once every 2 s. - Chat polish: captions and Try again use scheme tokens that read at 4.5:1 in every theme; a failed bubble carries "Couldn’t send" as its state; the hint row animates, Try again shows Connecting at once, and a refused first read says the phone lacks access; one battery-saver receiver per screen; faces and skeletons draw at about 30 fps; the separator lives in strings.xml; sent bubbles use the bubble helper; the chat follows only this Bot's detail, opens Advanced from the visit's read with no second GET, follows new rows only at the end or after a send, and the split button grows with large text. Co-Authored-By: Claude Opus 5.5 --- .../bots/AidenBotChatSemanticsUiTest.kt | 83 +++++++++++ .../features/bots/AidenBotActivity.kt | 121 +++++++++++++--- .../features/bots/AidenBotAdvancedScreen.kt | 2 + .../bots/AidenBotChatActivityModel.kt | 25 ++-- .../bots/AidenBotChatActivityScreen.kt | 40 +++++- .../features/bots/AidenBotProfileScreen.kt | 2 +- .../bots/AidenBotSemanticAvatarView.kt | 5 +- .../bots/AidenBotSessionController.kt | 15 +- .../features/bots/AidenBotSessionScreen.kt | 76 +++++++--- .../features/bots/AidenBotsHomeScreen.kt | 24 +++- .../features/chat/AidenComposerView.kt | 3 +- .../chat/AidenImageGenerationPlaceholder.kt | 21 ++- .../features/remote/AidenRemoteCoordinator.kt | 64 +++++++-- .../AidenOnTheGo/models/AidenInstallation.kt | 22 ++- .../persistence/AidenInstallationStore.kt | 7 +- .../AidenOnTheGo/ui/theme/AidenSkeleton.kt | 43 ++++-- android/app/src/main/res/values/strings.xml | 1 + .../AidenBotActivityContractTest.kt | 45 +++++- .../features/bots/AidenBotChatActivityTest.kt | 136 ++++++++++++++++-- .../bots/AidenBotChatPresentationTest.kt | 39 +++++ .../bots/AidenBotSessionControllerTest.kt | 25 ++++ 21 files changed, 677 insertions(+), 122 deletions(-) create mode 100644 android/app/src/androidTest/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatSemanticsUiTest.kt 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/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt index cf01db09f..90ed23617 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt @@ -16,6 +16,8 @@ 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 @@ -145,8 +147,8 @@ fun aidenBotFaceGlance(timeMs: Long): AidenBotFaceGlance { * `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?, cachedGrant: Boolean = false): AidenBotChatActivity? = - if (aidenBotActivitySupported(server, cachedGrant)) detail?.chatActivity else null +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 --- @@ -155,7 +157,9 @@ 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 + UNREACHABLE, + /** The Mac refused this phone (401/403) on the first read: no "Mac nearby", and no Try again. */ + NO_ACCESS } /** @@ -166,7 +170,11 @@ enum class AidenBotConnectionHint { 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 if (!ui.hasLoaded && ui.loadFailed) AidenBotConnectionHint.UNREACHABLE else AidenBotConnectionHint.CONNECTING + 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. */ @@ -199,6 +207,23 @@ fun aidenBotOutgoingBubble(look: AidenBotOutgoingLook): AidenBotPendingBubble = data class AidenBotBubbleColors(val container: Color, val content: Color) +/** + * The chat's quiet text on its background: [caption] for "Sending…", "Queued" and the + * connection hint, [link] for Try again. Both are scheme tokens that read at 4.5:1 or better in + * every theme (the accent alone doesn't in some light themes). + */ +data class AidenBotChatTextColors(val caption: Color, val link: Color) + +fun aidenBotChatTextColors(scheme: ColorScheme): AidenBotChatTextColors = + AidenBotChatTextColors(caption = scheme.onSurfaceVariant, link = scheme.secondary) + +/** + * 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 @@ -243,11 +268,17 @@ internal fun aidenBotIsResumed(): Boolean { /** 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, announced at most once every 2 seconds; the - * announcer starts over each time the line hides. + * 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) { @@ -269,6 +300,12 @@ fun AidenBotStatusLine(ui: AidenBotSessionUiState) { 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 @@ -288,7 +325,6 @@ fun AidenBotStatusLine(ui: AidenBotSessionUiState) { ) { val shown = line ?: last.line ?: return@AnimatedVisibility val shownWord = shown.display.word() - val spoken = announced?.text ?: shownWord // The mark grows with the person's text size. val markSize = 18.dp * LocalDensity.current.fontScale Row( @@ -297,9 +333,10 @@ fun AidenBotStatusLine(ui: AidenBotSessionUiState) { modifier = Modifier .fillMaxWidth() .padding(horizontal = 4.dp, vertical = 2.dp) + .testTag(AidenBotStatusLineTags.LINE) .clearAndSetSemantics { - liveRegion = LiveRegionMode.Polite - contentDescription = spoken + contentDescription = shownWord + shown.elapsed?.let { stateDescription = it } } ) { AidenActivityMark(mark = shown.display.mark, size = markSize, active = resumed, color = palette.secondary) @@ -339,7 +376,7 @@ private fun AidenBotPendingCaptionText(text: String) { Text( text, style = MaterialTheme.typography.labelSmall, - color = AidenTheme.palette.secondary, + color = aidenBotChatTextColors(MaterialTheme.colorScheme).caption, modifier = Modifier.padding(top = 2.dp, end = 4.dp) ) } @@ -383,23 +420,38 @@ fun AidenBotOutgoingBubble( return } val bubble = aidenBotOutgoingBubble(look) + val text = aidenBotChatTextColors(MaterialTheme.colorScheme) when (bubble.caption) { - AidenBotPendingCaption.FAILED -> AidenBotPendingBubbleView(message.text, faded = bubble.faded) { - Row(verticalAlignment = Alignment.CenterVertically) { + 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(stringResource(R.string.bot_session_send_failed), style = MaterialTheme.typography.labelMedium, color = palette.secondary) - Text(" ·", style = MaterialTheme.typography.labelMedium, color = palette.secondary, modifier = Modifier.clearAndSetSemantics {}) + 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) palette.accent else palette.secondary) + 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 = palette.secondary, modifier = Modifier.size(18.dp)) + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.bot_session_remove_message), tint = text.caption, modifier = Modifier.size(18.dp)) + } } } } @@ -414,10 +466,28 @@ fun AidenBotOutgoingBubble( } } -/** A quiet line above the composer while the Mac isn't reachable, never a blocking screen (R14). */ +/** + * 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 palette = AidenTheme.palette +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, @@ -425,18 +495,25 @@ fun AidenBotConnectionHintRow(hint: AidenBotConnectionHint, onTryAgain: () -> Un .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 = palette.secondary + 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 = palette.secondary, + color = text.caption, textAlign = TextAlign.Center, modifier = Modifier.weight(1f, fill = false) ) @@ -445,7 +522,7 @@ fun AidenBotConnectionHintRow(hint: AidenBotConnectionHint, onTryAgain: () -> Un contentPadding = PaddingValues(horizontal = 8.dp), modifier = Modifier.heightIn(min = AidenUi.MinimumTouchTarget) ) { - Text(stringResource(R.string.bot_session_try_again), style = MaterialTheme.typography.labelLarge, color = palette.accent) + 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 81cbd894d..cbf497dc1 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 @@ -371,6 +371,8 @@ fun AidenBotAdvancedSheet( 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, 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 index 37ab7d949..9f6268233 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt @@ -66,19 +66,19 @@ fun aidenBotChatActivitySummary(value: AidenBotChatActivity?): String { /** * Whether the Mac offers Bot activity (`bot-activity-v1`) and this phone holds `bot:activity`. - * Before `/server` has answered (a cold start, or offline), the grant negotiated and persisted - * for the pairing stands in: it is only ever requested from a Mac that announced the feature. - * Once `/server` answers it decides. + * 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?, cachedGrant: Boolean): Boolean = - server?.supportsBotActivity ?: cachedGrant +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, cachedGrant: Boolean = false): Boolean = - hasBaseline && aidenBotActivitySupported(server, cachedGrant) +fun aidenBotShowsChatActivity(server: AidenServer?, hasBaseline: Boolean, cachedSupport: Boolean = false): Boolean = + hasBaseline && aidenBotActivitySupported(server, cachedSupport) // --- The previews' script: what each level shows on the Mac --- @@ -242,7 +242,9 @@ class AidenBotChatActivityController( 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 + 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 { @@ -268,6 +270,7 @@ class AidenBotChatActivityController( } finally { pending = null _state.update { it.copy(isSaving = false, value = it.saved) } + onSettled() } } @@ -357,7 +360,8 @@ fun aidenBotChatActivityController( bot: AidenBotDetail, client: () -> AidenRemoteClient?, onSaved: (AidenBotDetail) -> Unit = {}, - waitForConnection: (suspend () -> Boolean)? = null + waitForConnection: (suspend () -> Boolean)? = null, + onSettled: () -> Unit = {} ): AidenBotChatActivityController { fun connected(): AidenRemoteClient = client() ?: throw AidenRemoteClientException.Disconnected() return AidenBotChatActivityController( @@ -367,6 +371,7 @@ fun aidenBotChatActivityController( }, refresh = { connected().bot(bot.id) }, onSaved = onSaved, - waitForConnection = waitForConnection + waitForConnection = waitForConnection, + onSettled = onSettled ) } 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 index cd3743875..cae7c84a5 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt @@ -74,10 +74,16 @@ class AidenBotChatActivityHost( val launch: (suspend () -> Unit) -> Unit, val cache: (AidenBotDetail) -> Unit ) { - private class Live(val controller: AidenBotChatActivityController, var listener: (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. @@ -85,6 +91,7 @@ class AidenBotChatActivityHost( 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 @@ -95,12 +102,29 @@ class AidenBotChatActivityHost( cache(saved) entry.listener(saved) }, - waitForConnection = waitForConnection + 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 = {} + } } /** @@ -113,15 +137,19 @@ suspend fun aidenBotAwaitConnected(connection: Flow 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. */ +/** + * 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 instance = activeInstanceId - fun samePairing() = activeInstanceId == instance + val pairing = activePairing + fun samePairing() = holdsPairing(pairing) AidenBotChatActivityHost( client = { if (samePairing()) client.value else null }, waitForConnection = { samePairing() && aidenBotAwaitConnected(connectionState, client) && samePairing() }, launch = { block -> launchDetached(block) }, - cache = { detail -> if (samePairing()) botCache.putBotDetail(detail) } + cache = pinnedBotCache() ) } 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 25bda780d..974e1f850 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 @@ -488,7 +488,7 @@ fun AidenBotProfileScreen( accept(it) showAdvanced = false }, - chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true, cachedGrant = coordinator.cachedBotActivityGrant), + 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/AidenBotSemanticAvatarView.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSemanticAvatarView.kt index de803676c..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 @@ -23,6 +23,7 @@ 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 /** @@ -99,7 +100,9 @@ fun AidenBotSemanticAvatarView( while (true) { withFrameMillis { frameMs -> if (startMs == Long.MIN_VALUE) startMs = frameMs - clockMs.longValue = frameMs - startMs + // 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 } } } 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 863e268ec..fc5b6912d 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 @@ -263,6 +263,8 @@ data class AidenBotSessionUiState( 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. */ @@ -550,6 +552,8 @@ class AidenBotSessionController( } 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 = bounded { transport.session(botId) } val current = _state.value.session @@ -563,8 +567,11 @@ class AidenBotSessionController( throw error } catch (error: Exception) { val missing = (error as? AidenRemoteClientException.Server)?.statusCode == 404 - if (aidenBotLostAccess(error)) purgeForAccessLoss() - _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 } @@ -608,7 +615,7 @@ class AidenBotSessionController( /** 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) } + _state.update { it.copy(session = session, isLoading = false, loadFailed = false, hasLoaded = true, accessLost = false) } settleCards(session) questionWrites += 1 approvalWrites += 1 @@ -699,7 +706,7 @@ class AidenBotSessionController( if (aidenBotLostAccess(error)) { downJob?.cancel() purgeForAccessLoss() - _state.update { it.copy(isLoading = false, loadFailed = it.session == null) } + _state.update { it.copy(isLoading = false, loadFailed = it.session == null, accessLost = true) } return } clean = false 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 fd5162ccf..18d52baeb 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,7 +40,10 @@ 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 @@ -119,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 @@ -129,7 +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.cachedBotActivityGrant) + 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) @@ -157,15 +176,23 @@ fun AidenBotSessionScreen( 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. - val botDetails by coordinator.botCache.botDetails.collectAsStateWithLifecycle() - val chatActivity = aidenBotChatActivity(serverInfo, botDetails[botId], coordinator.cachedBotActivityGrant) + // 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 { - coordinator.botCache.putBotDetail(cl.bot(botId)) + 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) { @@ -181,21 +208,26 @@ fun AidenBotSessionScreen( // "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) { throw e } catch (_: Exception) { - // Offline: Advanced still opens from the cached Bot, so Chat activity stays reachable. - val cached = coordinator.botCache.getBotDetail(botId) - if (cached != null) advancedBot = cached - else coordinator.presentError(resources.getString(R.string.bot_session_unreachable)) + coordinator.presentError(resources.getString(R.string.bot_session_unreachable)) } finally { openingAdvanced = false } @@ -234,13 +266,19 @@ fun AidenBotSessionScreen( 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, ui.queued.size, outgoing.size, ui.session?.activity?.phase ) { - val count = listState.layoutInfo.totalItemsCount - if (count > 0) listState.animateScrollToItem(count - 1) + 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) { @@ -273,16 +311,18 @@ fun AidenBotSessionScreen( advancedBot?.let { bot -> val chatActivityHost = remember(cl) { coordinator.botChatActivityHost() } + val pinnedCache = remember(cl) { coordinator.pinnedBotCache() } AidenBotAdvancedSheet( bot = bot, client = cl, onDismiss = { advancedBot = null }, + // A save that lands after an unpair or re-pair never reaches the other pairing's cache. onSaved = { saved -> - coordinator.botCache.putBotDetail(saved) + pinnedCache(saved) advancedBot = null scope.launch { controller.refetch() } }, - chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true, cachedGrant = coordinator.cachedBotActivityGrant), + 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 -> @@ -434,9 +474,7 @@ fun AidenBotSessionScreen( } } } - aidenBotConnectionHint(ui, System.currentTimeMillis())?.let { hint -> - AidenBotConnectionHintRow(hint, onTryAgain = { scope.launch { controller.refetch() } }) - } + AidenBotConnectionHintRow(aidenBotConnectionHint(ui, System.currentTimeMillis()), onTryAgain = { scope.launch { controller.refetch() } }) AidenBotSessionComposer( draft = draft, onDraftChange = { draft = it.take(AidenBotSessionWire.MAX_MESSAGE_LENGTH) }, @@ -450,6 +488,7 @@ fun AidenBotSessionScreen( val text = draft val mode = if (ui.offersBusyModes) busyMode else null draft = "" + justSent = true scope.launch { if (!controller.send(text, mode) && draft.isEmpty()) draft = text } @@ -490,11 +529,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) ) } 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 82f1f892b..1c34da85c 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 @@ -50,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 @@ -220,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 @@ -448,7 +470,7 @@ fun AidenBotsHomeScreen( advancedClient = null scope.launch { viewModel.loadBots(force = true) } }, - chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true, cachedGrant = coordinator.cachedBotActivityGrant), + chatActivityAvailable = aidenBotShowsChatActivity(serverInfo, hasBaseline = true, cachedSupport = coordinator.cachedBotActivitySupported), chatActivityHost = remember(advancedClient) { coordinator.botChatActivityHost() } ) } 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 6133b7770..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 @@ -649,9 +649,10 @@ internal 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 5223aaac1..8cc161d4d 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 @@ -68,6 +68,16 @@ internal fun aidenProgressCapabilitiesToRequest( .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, @@ -143,12 +153,29 @@ 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 + + /** + * 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) } + } + /** * 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(activeInstanceId) + val entry = DetachedWork(activePairing) val job = scope.launch { block() } entry.job = job detachedWork += entry @@ -156,27 +183,31 @@ class AidenRemoteCoordinator( return job } - private class DetachedWork(val instanceId: String?) { + 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: (String?) -> Boolean) { - detachedWork.filterNot { keep(it.instanceId) }.forEach { it.job.cancel() } + private fun cancelDetachedWork(keep: (AidenPairingIdentity?) -> Boolean) { + detachedWork.filterNot { keep(it.pairing) }.forEach { it.job.cancel() } } - private val pairingMemos = mutableMapOf, Any>() + 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(activeInstanceId to key) { create() } as T + pairingMemos.getOrPut(activePairing to key) { create() } as T - /** What Chat activity persisted for the active pairing; stands in until `/server` answers. */ - val cachedBotActivityGrant: Boolean - get() = installationStore.activeInstallation?.hasNegotiatedAccess(AidenRemoteCapability.BOT_ACTIVITY) == true + /** + * 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 @@ -220,7 +251,7 @@ class AidenRemoteCoordinator( activationGeneration += 1 val generation = activationGeneration val installation = installationStore.activeInstallation - cancelDetachedWork { it == installation?.instanceId } + cancelDetachedWork { it != null && it == installation?.pairing } if (installation == null) { _client.value = null _serverInfo.value = null @@ -262,7 +293,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, @@ -275,7 +307,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. @@ -327,7 +360,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 @@ -386,8 +420,8 @@ class AidenRemoteCoordinator( .orEmpty() ) } - cancelDetachedWork { it != installation.instanceId } - pairingMemos.keys.removeAll { it.first == installation.instanceId } + 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) 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 fec6f788c..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,13 +148,16 @@ data class AidenServer( val offersBotCards: Boolean get() = supportsBotMemory || supportsBotProactive - /** The Mac offers `bot:activity` (revision 30), so this phone asks for it. */ + /** + * 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() = features.contains(AidenRemoteProtocol.BOT_ACTIVITY_FEATURE) + 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() = offersBotActivity && capabilities.contains(AidenRemoteCapability.BOT_ACTIVITY) + get() = features.contains(AidenRemoteProtocol.BOT_ACTIVITY_FEATURE) && capabilities.contains(AidenRemoteCapability.BOT_ACTIVITY) /** * Chat messages may carry inline `visuals` (revision 28). Informational: @@ -267,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/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/ui/theme/AidenSkeleton.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/ui/theme/AidenSkeleton.kt index cd4fe0e23..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,6 +30,18 @@ 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 @@ -53,13 +62,19 @@ fun AidenSkeletonBlock( 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 fb0f7c7cb..09f096614 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -687,6 +687,7 @@ 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. diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt index a75e9c47e..2ed901783 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt @@ -336,10 +336,16 @@ class AidenBotActivityContractTest { } // Test 8: the opt-in is requested only from a Mac that offers it. - private fun macWith(features: List, capabilities: List) = AidenServer( + /** [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 ) @@ -348,16 +354,19 @@ class AidenBotActivityContractTest { val feature = AidenRemoteProtocol.BOT_ACTIVITY_FEATURE val grant = AidenRemoteCapability.BOT_ACTIVITY val base = listOf(AidenRemoteCapability.CHAT_READ) - // offers / supports: (feature, grant) -> (offers, supports) + // 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) - assertTrue(featureOnly.offersBotActivity) + assertFalse("announced, but not offered to this phone", featureOnly.offersBotActivity) assertFalse(featureOnly.supportsBotActivity) - // A grant without the feature is not an offer: an older Mac is never asked, and never trusted. + 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.offersBotActivity) assertFalse(grantOnly.supportsBotActivity) val both = macWith(listOf(feature), base + grant) assertTrue(both.offersBotActivity) @@ -380,7 +389,6 @@ class AidenBotActivityContractTest { @Test fun aGrantIsAskedForOnlyFromAMacThatAnnouncesItsFeatureAndOnlyWhenNotHeld() { val rows = listOf( - AidenRemoteProtocol.BOT_ACTIVITY_FEATURE to listOf(AidenRemoteCapability.BOT_ACTIVITY), 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), @@ -410,13 +418,36 @@ class AidenBotActivityContractTest { ) } // 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()) + 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")) 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 index a282e6443..b404bcaec 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityTest.kt @@ -626,10 +626,14 @@ class AidenBotChatActivityTest { // --- The pairing pin (coordinator level) --- - private fun exchange(instance: String, capabilities: List = listOf(AidenRemoteCapability.CHAT_READ)) = + private fun exchange( + instance: String, + capabilities: List = listOf(AidenRemoteCapability.CHAT_READ), + deviceId: String = "device-$instance" + ) = AidenPairingExchange( instanceId = instance, - deviceId = "device-$instance", + deviceId = deviceId, endpoint = "https://$instance.test/api/aiden/v1", serverSpkiSha256 = "sha256/$instance", credential = "secret-$instance", @@ -693,17 +697,119 @@ class AidenBotChatActivityTest { 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 theCachedGrantIsTheNegotiatedOneForThePairingInUse() { + fun theCachedSupportIsTheNegotiatedOneForThePairingInUse() { val installations = AidenInstallationStore(tempFolder.root, InMemoryAidenSecureStore()) val coordinator = coordinatorFor(installations) - installations.addInstallation( - exchange("one", listOf(AidenRemoteCapability.CHAT_READ, AidenRemoteCapability.BOT_READ, AidenRemoteCapability.BOT_ACTIVITY)), - null - ) - assertTrue(coordinator.cachedBotActivityGrant) + 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.cachedBotActivityGrant) + 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. @@ -759,15 +865,15 @@ class AidenBotChatActivityTest { fun theRowSurvivesAColdOfflineStartOnThePersistedGrant() { fun mac(features: List, capabilities: List) = AidenServer(instanceId = "inst", name = "Mac", capabilities = capabilities, features = features) - assertTrue(aidenBotShowsChatActivity(null, hasBaseline = true, cachedGrant = true)) - assertFalse("never granted", aidenBotShowsChatActivity(null, hasBaseline = true, cachedGrant = false)) - assertFalse("still needs a Bot on screen", aidenBotShowsChatActivity(null, hasBaseline = false, cachedGrant = true)) + 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, cachedGrant = true) + aidenBotShowsChatActivity(mac(emptyList(), listOf(AidenRemoteCapability.CHAT_READ)), hasBaseline = true, cachedSupport = true) ) - assertTrue(aidenBotActivitySupported(null, cachedGrant = true)) - assertFalse(aidenBotActivitySupported(null, cachedGrant = false)) + assertTrue(aidenBotActivitySupported(null, cachedSupport = true)) + assertFalse(aidenBotActivitySupported(null, cachedSupport = false)) } // Previews: what each level shows on the Mac. 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 index 73fbd99d5..79524c8a5 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatPresentationTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatPresentationTest.kt @@ -9,6 +9,7 @@ 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 { @@ -46,6 +47,44 @@ class AidenBotChatPresentationTest { } } + @Test + fun captionsAndTryAgainReadOnTheChatInEveryTheme() { + 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"}" + val caption = AidenPalette.contrastRatio(text.caption, scheme.background) + val link = AidenPalette.contrastRatio(text.link, scheme.background) + assertTrue("$name: caption contrast $caption is below 4.5:1", caption >= 4.5f) + assertTrue("$name: Try again contrast $link is below 4.5:1", link >= 4.5f) + } + } + } + + @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)) 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 5f8ecf940..4958afc27 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 @@ -2202,4 +2202,29 @@ class AidenBotSessionControllerTest { "${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() + } } From a9a46743ee9d0163896d6028d1fe6459107ddd41 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 18:13:29 -0400 Subject: [PATCH 41/83] Docs: Bot chat activity final fix wave, desktop and protocol outcomes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §17 and the API doc record the shared run start (FR-I1), R22 (a refusal from state leaves no ledger record), the shared fail-closed classifier, once-per-streak placement reports, the null-literal fixture samples and the new tests. The detail routes that strip chatActivity are create, GET, PATCH and avatar DELETE (no restore or archive routes; avatar PUT returns asset metadata). R21's status, R22 and the stale open review rounds are corrected; the memory file names R1-R22. The phone outcomes are left as a TODO for the controller. Co-Authored-By: Claude Opus 5.5 --- .memory/bot-chat-activity.md | 23 ++++++++------ docs/aiden-remote-api-v1.md | 8 ++--- .../2026-10-14-bot-chat-activity-design.md | 30 ++++++++++++------- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/.memory/bot-chat-activity.md b/.memory/bot-chat-activity.md index 4c312efd3..d4a7a975f 100644 --- a/.memory/bot-chat-activity.md +++ b/.memory/bot-chat-activity.md @@ -47,7 +47,7 @@ Spec: docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md (§17 lists - 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–R21); 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"). +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. @@ -56,10 +56,13 @@ Plan: docs/superpowers/plans/2026-10-14-bot-chat-activity-d-phones.md. Spec §11 - 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. +- 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). +- 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 (any rejection is still recorded as `internal_error` and replayed). 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. - `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. +- `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`. @@ -72,7 +75,7 @@ Plan: docs/superpowers/plans/2026-10-14-bot-chat-activity-d-phones.md. Spec §11 - **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`, `aiden-remote-state.test.ts`, `aiden-remote-pairing.test.ts`, `aiden-remote-bots.test.ts`, `aiden-remote-bot-session.test.ts`, `main/services/bot-runtime/live-projection.test.ts` (intents, Stop isolation, `resumedFrom`). +- 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. @@ -80,10 +83,12 @@ Plan: docs/superpowers/plans/2026-10-14-bot-chat-activity-d-phones.md. Spec §11 ### 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. -### Parked and final-wave (not done; full list in spec §17) -- R15 (slice A phone surfaces: instant apply, skeletons) and R21 (cache last-known server capabilities per installation, so the line and Steer/Queue work before `serverInfo` arrives; cold offline start loses the Chat activity row until then); the iOS still badge (R19). -- iOS chat polish (44 pt hit areas, semantic dimming token, glance 30 fps cap, `@ScaledMetric`, `start()` not blocking on `loadSummary`, announcer reset, endless-skeleton failure state); iOS Chat activity (late-save reporting through the cache, offline copy, off-screen pause check inside a `Form`, Mac phase words for running labels). -- Android chat polish and controller hardening (scroll-follow, contrast tokens, one power-save receiver, lock scope, restore-on-throw, caps after merge), and the 11b review round (unpinned cache writes, untested pin and `launchDetached`, jobs not cancelled on unpair). -- Desktop/protocol test gaps (reconnect snapshot mid-run, 40-code-point app cut, identical phase emits nothing, partial-wiring gate, memo-prune, `setTimeout(50)` negative assertion), a shared fail-closed classifier for desktop and Remote, `placed()` failure reporting once per streak, null-literal fixture samples. +### 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. +- TODO(controller): record the iOS and Android final-wave outcomes (R15, R19 on iOS, R21, FR-I2, FR-I3, FR-m1 to FR-m8, chat and Chat activity polish, the Android raw-null guard) from `final-fix-report-ios.md` and `final-fix-report-android.md`, which did not exist when this was written, and drop what they closed from the parked list below. + +### Parked (full list in spec §17) +- Phone items the final wave did not close (see the TODO above). - 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/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index 3c48827e0..f5ec5cb12 100644 --- a/docs/aiden-remote-api-v1.md +++ b/docs/aiden-remote-api-v1.md @@ -641,18 +641,18 @@ Phones show what a Bot's running turn is doing ("Reading", "Running a command"), **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. 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. +- `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. - 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`. +**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, Bots held by another Aiden process or a paused turn; `500 internal_error` for a model that can't run right now) 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, restore, archive, avatar PUT and DELETE) strips the field for other devices. +- `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). TypeScript, Swift and Kotlin decode them. +**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/superpowers/specs/2026-10-14-bot-chat-activity-design.md b/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md index a6df8073b..223395aee 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,7 +292,7 @@ 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 (GET, PATCH, restore, archive, avatar PUT and DELETE) 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. +- **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. @@ -441,10 +441,10 @@ Every new test file is registered in the matching `package.json` script and `scr - 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 (throttled retry through the projector's serial lane). Routine and intro inputs never reach a phone. The projector keeps only personal ids (inclusion filter), and a classification failure is reported once per failing streak. + - **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". + - **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. @@ -456,18 +456,28 @@ Every new test file is registered in the matching `package.json` script and `scr - **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, not shipped): cache the last known server capabilities per installation so the line, Steer/Queue and `whenBusy` work before `serverInfo` arrives. - - **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 PUT/DELETE leaking it); `session` and `openEvents` activity are gated on the service being fully wired. + - **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. Android ships the cached-grant path and persists the feature token in the final wave; iOS adopts it in the same wave. + - TODO(controller): confirm the R21 outcome on both phones from `final-fix-report-ios.md` and `final-fix-report-android.md`, which did not exist when this was written. + - **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. - - **Parked and final-wave items (not done in D as of this note).** They are follow-ups, not shipped: - - Desktop and protocol: a shared fail-closed classifier used by desktop and Remote; storing `resumedFrom` baseline serialized once; tests for a reconnect snapshot mid-run carrying activity and queued, `projectBotSessionActivity` (app phase and 40-code-point cut), an identical phase emitting no frame, and the session/openEvents gate under partial wiring; an observable memo-prune test for `submissionId`; reporting once per failing `placed()` streak; replacing the fixed `setTimeout(50)` negative assertion; overlapping Stops where one fails briefly clear "Stopping" (cosmetic). - - Fixtures: null-literal rejected samples (`app: null`, `queued: null`, `label: null` with `submissionId`) and an Android raw-null guard; a whitespace-only `app` label parity note. + - **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. + - TODO(controller): add the iOS and Android final-wave outcomes here from `final-fix-report-ios.md` and `final-fix-report-android.md` (not yet written when this was), and trim the phone items below that they closed. + - **Parked and final-wave items (phones; open when this was written).** They are follow-ups unless the phone reports above close them: + - Desktop: overlapping Stops where one fails briefly clear "Stopping" (cosmetic). + - Fixtures: the Android raw-null guard for the null-literal samples; a whitespace-only `app` label parity note. - iOS chat polish: Try again and remove hit areas of at least 44 pt; the Steer/Queue menu reachable with an empty draft; Queued/Sending dimming through a semantic token instead of 0.55 opacity (contrast of at least 4.5:1); glance `TimelineView` minimum interval 1/30; reset the announcer when the line hides; a `start()` failure before the model exists should show a hint and Try again, not an endless skeleton; stop the shimmer when unreachable; `@ScaledMetric` for the status mark and composer row; `start()` must not block `listen()` or the outbox on `loadSummary`; drop the dead `.id("status")`; pure helper tests for the busy-mode default and reset; bound the Chat activity test polls; R19 still badge for canonical faces on iOS. - iOS Chat activity: verify the off-screen preview pause inside a `Form` manually (fall back to `onAppear`/`onDisappear`); route a late save's `onSaved` through the cache/coordinator so it reaches the screens behind a dismissed view; copy for the offline case distinct from "Couldn't Save"; running labels use the Mac's phase words; refresh after a Timeout; confirmed-after-leave still calls `onSaved`. - 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. - - Open review rounds when this was written: Android Chat activity callers re-write the Bot cache unpinned (pin bypass, double write), the pin and `launchDetached` lack tests, detached jobs are not cancelled on unpair, and a cold offline start loses the Chat activity row until R21 (persisted capabilities) lands; iOS fix round 3 (outbox exhaustion, bounded requests, ordered writes) was awaiting re-review. - - R15 and R21 above, and 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). + - Final review findings for the phones: Android to set an unreadable outbox aside with a notice instead of dropping it (FR-I2) and to bound every Bot request to 12 s (FR-I3); both phones to request `bot:activity` only when the server's capability list offers it (FR-m3), to share copy (FR-m4) and to keep an unechoed sent message through a snapshot grace period (FR-m5). + - R15 (slice A phone surfaces apply at once) and R21 above, and 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). From fd029a31d38970e6426fbc53d01bbf799d194e06 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 18:14:14 -0400 Subject: [PATCH 42/83] Docs: Bot chat activity final fix wave, iOS outcomes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §17 and the memory file record the iOS final wave from its report: the clock gate, shared copy, snapshot grace, still badge, cached capabilities (R21), instant slice-A surfaces (R15) and the chat and Chat activity polish. Android's outcomes stay a TODO for the controller. Co-Authored-By: Claude Opus 5.5 --- .memory/bot-chat-activity.md | 7 +++--- .../2026-10-14-bot-chat-activity-design.md | 23 ++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.memory/bot-chat-activity.md b/.memory/bot-chat-activity.md index d4a7a975f..5943249ec 100644 --- a/.memory/bot-chat-activity.md +++ b/.memory/bot-chat-activity.md @@ -66,7 +66,7 @@ Plan: docs/superpowers/plans/2026-10-14-bot-chat-activity-d-phones.md. Spec §11 ### 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 (shipped on Android, iOS is open). +- 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. @@ -86,9 +86,10 @@ If `main` has moved past 29, change every `30` D introduced to the next free rev ### 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. -- TODO(controller): record the iOS and Android final-wave outcomes (R15, R19 on iOS, R21, FR-I2, FR-I3, FR-m1 to FR-m8, chat and Chat activity polish, the Android raw-null guard) from `final-fix-report-ios.md` and `final-fix-report-android.md`, which did not exist when this was written, and drop what they closed from the parked list below. +- 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. +- TODO(controller): record the Android final-wave outcomes (FR-I2, FR-I3, FR-m2 to FR-m8, R15, R21 completion, polish, the raw-null guard) from `final-fix-report-android.md`, which did not exist when this was written, and drop what they closed from the parked list below. ### Parked (full list in spec §17) -- Phone items the final wave did not close (see the TODO above). +- Android items the final wave did not close (see the TODO above); the iOS Files sheet `ProgressView`. - 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/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md b/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md index 223395aee..edb3d89c6 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 @@ -456,8 +456,8 @@ Every new test file is registered in the matching `package.json` script and `scr - **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. Android ships the cached-grant path and persists the feature token in the final wave; iOS adopts it in the same wave. - - TODO(controller): confirm the R21 outcome on both phones from `final-fix-report-ios.md` and `final-fix-report-android.md`, which did not exist when this was written. + - **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. + - TODO(controller): confirm the Android R21 outcome from `final-fix-report-android.md`, which did not exist when this was written. - **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. @@ -470,14 +470,21 @@ Every new test file is registered in the matching `package.json` script and `scr - 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. - - TODO(controller): add the iOS and Android final-wave outcomes here from `final-fix-report-ios.md` and `final-fix-report-android.md` (not yet written when this was), and trim the phone items below that they closed. - - **Parked and final-wave items (phones; open when this was written).** They are follow-ups unless the phone reports above close them: + - **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). + - Open on iOS: the Files sheet in `AidenBotChatToolsView` still shows a `ProgressView`; no manual VoiceOver pass or live-Mac run. + - TODO(controller): add the Android final-wave outcomes from `final-fix-report-android.md` (not yet written when this was) and trim the Android items below that they closed. + - **Parked and final-wave items (open when this was written).** They are follow-ups unless the Android report closes them: - Desktop: overlapping Stops where one fails briefly clear "Stopping" (cosmetic). - Fixtures: the Android raw-null guard for the null-literal samples; a whitespace-only `app` label parity note. - - iOS chat polish: Try again and remove hit areas of at least 44 pt; the Steer/Queue menu reachable with an empty draft; Queued/Sending dimming through a semantic token instead of 0.55 opacity (contrast of at least 4.5:1); glance `TimelineView` minimum interval 1/30; reset the announcer when the line hides; a `start()` failure before the model exists should show a hint and Try again, not an endless skeleton; stop the shimmer when unreachable; `@ScaledMetric` for the status mark and composer row; `start()` must not block `listen()` or the outbox on `loadSummary`; drop the dead `.id("status")`; pure helper tests for the busy-mode default and reset; bound the Chat activity test polls; R19 still badge for canonical faces on iOS. - - iOS Chat activity: verify the off-screen preview pause inside a `Form` manually (fall back to `onAppear`/`onDisappear`); route a late save's `onSaved` through the cache/coordinator so it reaches the screens behind a dismissed view; copy for the offline case distinct from "Couldn't Save"; running labels use the Mac's phase words; refresh after a Timeout; confirmed-after-leave still calls `onSaved`. - 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 the phones: Android to set an unreadable outbox aside with a notice instead of dropping it (FR-I2) and to bound every Bot request to 12 s (FR-I3); both phones to request `bot:activity` only when the server's capability list offers it (FR-m3), to share copy (FR-m4) and to keep an unechoed sent message through a snapshot grace period (FR-m5). - - R15 (slice A phone surfaces apply at once) and R21 above, and 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). + - 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). From 237e9e8614cae43ee1c86c39782d143867d26622 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 18:27:56 -0400 Subject: [PATCH 43/83] Android Bots: slice-A edits apply at once (R15), unheld grace as on iOS - R15: Advanced Save (model pick and access) shows the optimistic Bot at once, closes, saves on the pairing's scope (waiting up to 8 s for the Mac, bounded to 12 s) and puts the Bot back, unless something newer replaced it, with an error. Advanced opens from the cached Bot and fills in when the Mac connects; no "connect" text. - Profile name, subtitle, character and the Instructions editor share one per-Bot AidenBotIdentityEditor: each edit shows at once on every screen, edits made meanwhile save next on the newest revision, a failed one goes back (later edits stay), and a late success shows as saved. Profile opens on skeletons while connecting; the photo upload shimmers over the face instead of a progress bar. - Memory: Save, Delete and Erase apply at once, go back on failure (an edit reopens on the person's words with the reason), and the page waits for the Mac on skeletons instead of a "connect" screen. - Create no longer waits on a client to be tapped; it waits for the Mac and is bounded, a timeout replays the same key (AidenBotCreateAttempts), and the model field shows a placeholder while the catalog arrives. - FR-m5 aligned with iOS: a snapshot marks an unheld SENT message; it goes only once 10 s have passed since the mark, and showing up queued again clears it. - Test names made accurate. Co-Authored-By: Claude Opus 5.5 --- .../features/bots/AidenBotAdvancedScreen.kt | 81 ++++- .../bots/AidenBotChatActivityModel.kt | 7 +- .../features/bots/AidenBotEditorScreen.kt | 74 +++-- .../features/bots/AidenBotMemoryController.kt | 117 +++++-- .../features/bots/AidenBotMemoryScreen.kt | 27 +- .../features/bots/AidenBotOptimisticSaves.kt | 256 +++++++++++++++ .../features/bots/AidenBotProfileScreen.kt | 109 ++++--- .../bots/AidenBotSessionController.kt | 16 +- .../features/bots/AidenBotSessionScreen.kt | 11 +- .../features/bots/AidenBotsHomeScreen.kt | 24 +- android/app/src/main/res/values/strings.xml | 6 +- .../features/bots/AidenBotMemoryTest.kt | 91 ++++++ .../bots/AidenBotOptimisticSavesTest.kt | 306 ++++++++++++++++++ .../bots/AidenBotSessionControllerTest.kt | 32 +- 14 files changed, 996 insertions(+), 161 deletions(-) create mode 100644 android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSaves.kt create mode 100644 android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt 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 cbf497dc1..47af3f14e 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 @@ -152,6 +152,56 @@ 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]. + */ +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.cache(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.cache(bot) + settled(bot) + failed(aidenBotSaveFailure(error)) + } + } +} + /** Saves an Advanced draft's access and returns the fresh Bot. */ suspend fun aidenBotSaveAdvanced( client: AidenRemoteClient, @@ -201,7 +251,15 @@ fun AidenBotAdvancedSheet( /** 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 = {} + 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 @@ -225,11 +283,10 @@ fun AidenBotAdvancedSheet( 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 @@ -260,8 +317,20 @@ 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 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 index 9f6268233..7f134e48b 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityModel.kt @@ -195,8 +195,11 @@ data class AidenBotChatActivityUiState( val saveFailed: Boolean get() = saveFailure != null } -/** A save that outran its limit; the pick returns to the saved choice. */ -class AidenBotChatActivityTimeout : Exception("The save took too long.") +/** + * 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.") 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..b7d946a65 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 @@ -10,6 +10,9 @@ 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 @@ -200,7 +203,9 @@ private fun AidenBotCreateScreen( var isSaving by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } // 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,39 +214,47 @@ private fun AidenBotCreateScreen( } catch (e: CancellationException) { throw e } catch (_: Exception) { + } finally { + catalogSettled = true } } + // Create never waits on the connection to be tapped: it waits for the Mac, then sends (R15). + val saveHost = remember(client) { coordinator.botSaveHost() } // 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), + actionEnabled = !isSaving && (attempts.replays(draft) || draft.request(catalog) != null), onNavigateBack = onNavigateBack, 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) + val created = aidenBotHeldRequest(saveHost.waitForConnection) { saveHost.connected().createBot(attempt.request, attempt.key) } + saveHost.cache(created) onBotSaved(created.id) } catch (e: CancellationException) { throw e } catch (e: Exception) { + // No answer (offline, too slow) keeps the attempt, so Create replays its key. 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() + saveHost.client()?.let { catalog = it.botCapabilityCatalog() } } catch (c: CancellationException) { throw c } catch (_: Exception) { } - error = if (draft.modelIsGone(catalog)) null else resources.getString(R.string.bot_editor_create_failed) + error = when { + draft.modelIsGone(catalog) -> null + aidenBotChatActivityMacUnreachable(e) -> resources.getString(R.string.bot_editor_create_unreachable) + else -> resources.getString(R.string.bot_editor_create_failed) + } } finally { isSaving = false } @@ -268,6 +281,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) @@ -314,12 +338,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 +366,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) }, 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..e07723e40 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, @@ -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) @@ -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() } 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..38aef5d87 --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSaves.kt @@ -0,0 +1,256 @@ +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 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 one pinned place a saved Bot is cached. + */ +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, + 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 = { if (samePairing()) client.value else null }, + waitForConnection = { samePairing() && aidenBotAwaitConnected(connectionState, client) && samePairing() }, + launch = { block -> launchDetached(block) }, + 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): the cache follows it, so every screen shows it at once. */ + 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. The pinned cache holds what it + * shows, so the change appears on every screen at once and goes back with a rollback. + */ +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 = onSaved, + onShown = host.cache, + 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/AidenBotProfileScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotProfileScreen.kt index 974e1f850..e9e751178 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,8 +131,15 @@ 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() @@ -139,10 +149,29 @@ fun AidenBotProfileScreen( bot = detail nameText = detail.name subtitleText = detail.purpose - coordinator.botCache.putBotDetail(detail) + // Pinned: a Bot from another pairing's save never lands in this one's cache. + saveHost.cache(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 +184,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 +220,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 +228,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 -> @@ -304,7 +309,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 +350,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 }, @@ -383,9 +399,6 @@ 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) } Surface(color = palette.raised, shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth()) { @@ -410,10 +423,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 +486,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 = palette.danger, style = MaterialTheme.typography.bodySmall) + } } } } @@ -484,10 +499,14 @@ 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) showAdvanced = false }, + saveHost = saveHost, + onSaveSettled = ::accept, + 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. 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 fc5b6912d..56a6b7515 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 @@ -879,9 +879,10 @@ class AidenBotSessionController( * 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 goes once a later snapshot still - * finds it so, or [unheldGraceMillis] after the first one did (FR-m5): the Mac's Queued list - * may be full, or may leave a message out while it can't yet tell whose it is. + * 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 @@ -891,7 +892,6 @@ class AidenBotSessionController( claimedEntries.clear() } if (current.outbox.isEmpty()) return - val queueFull = current.queued.size >= AidenBotQueuedMessage.MAX_COUNT val entries = current.session?.entries.orEmpty() val owned = current.outbox.mapNotNull { it.submissionId }.toSet() val queuedIds = current.queued.map { it.submissionId }.toSet() @@ -919,12 +919,10 @@ class AidenBotSessionController( 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 { - // A full queue can't show it; it stays until the Mac's view has room. - queueFull -> message = message.copy(unheld = false) - // A second snapshot agrees, or the grace period ran out ([unheldTimers]). - message.unheld && (snapshot || message.key in unheldExpired) -> continue - snapshot -> message = message.copy(unheld = true) + message.unheld && message.key in unheldExpired -> continue + snapshot && !message.unheld -> message = message.copy(unheld = true) } } } else if (message.queuedId == null) { 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 18d52baeb..d246f11dc 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 @@ -311,17 +311,20 @@ private fun AidenBotSessionContent( advancedBot?.let { bot -> val chatActivityHost = remember(cl) { coordinator.botChatActivityHost() } - val pinnedCache = remember(cl) { coordinator.pinnedBotCache() } + val saveHost = remember(cl) { coordinator.botSaveHost() } AidenBotAdvancedSheet( bot = bot, client = cl, onDismiss = { advancedBot = null }, - // A save that lands after an unpair or re-pair never reaches the other pairing's cache. - onSaved = { saved -> - pinnedCache(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. 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 1c34da85c..3c6a53d93 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 @@ -268,7 +268,16 @@ private fun AidenBotsHomeContent( 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 { @@ -299,8 +308,11 @@ private fun AidenBotsHomeContent( } } // 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 } @@ -464,12 +476,14 @@ private fun AidenBotsHomeContent( 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() } ) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 09f096614..ef9162276 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 @@ -716,6 +713,7 @@ New Bot Creating… Aiden couldn’t create this Bot. 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/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..833e07e6e --- /dev/null +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt @@ -0,0 +1,306 @@ +package sbtbiswas.AidenOnTheGo.features.bots + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +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 +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.models.* +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteClientException +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteErrorCode +import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteErrorEnvelope +import java.io.IOException +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 { + 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 shown = mutableListOf() + val settled = mutableListOf() + val failures = mutableListOf() + val host = AidenBotSaveHost( + client = { null }, + waitForConnection = { false }, + launch = { block -> launch { block() } }, + cache = { cache[it.id] = 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) + } + + @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() } }, + 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 aCreateThatTimedOutReplaysItsKey() { + val keys = ArrayDeque(List(2) { UUID.randomUUID() }) + val attempts = AidenBotCreateAttempts { keys.removeFirst() } + val draft = AidenBotCreateDraft(name = "Chef", help = "Plans meals") + val first = attempts.begin(draft, catalog)!! + attempts.failed(first, AidenBotChatActivityTimeout()) + assertTrue(attempts.replays(draft)) + assertEquals("the Mac may have made it", first.key, attempts.begin(draft, catalog)!!.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 4958afc27..a97b57805 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 @@ -1519,7 +1519,7 @@ class AidenBotSessionControllerTest { } @Test - fun aCachedChatShowsItsTranscriptWithConnectingAndAMissingBotShowsNeither() = runTest { + 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) @@ -1542,7 +1542,7 @@ class AidenBotSessionControllerTest { } @Test - fun aDroppedFeedSaysReconnectingOnceOnTheLineAndConnectingWhenIdle() = runTest { + fun aDroppedFeedSaysReconnectingOnTheLineAloneAndConnectingWhenIdle() = runTest { val dropped = flow { throw IOException("Wi-Fi dropped") } val working = FakeTransport(activitySession) working.feeds += flow { @@ -2125,11 +2125,11 @@ class AidenBotSessionControllerTest { 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 goes only on a second look, or after the grace period. + // FR-m5: a taken message nothing on the Mac shows is marked unheld and goes only once 10 s have passed. @Test - fun aTakenMessageNothingOnTheMacShowsGoesOnlyOnASecondLookOrAfterItsGrace() = runTest { + fun aTakenMessageNothingOnTheMacShowsGoesOnlyAfterItsGrace() = runTest { val transport = FakeTransport(runningSession) - transport.receiptIds += listOf("21", "22", "23") + transport.receiptIds += listOf("21", "22") val controller = AidenBotSessionController(runningSession.botId, transport, backgroundScope, activitySupported = true) controller.refetch() var seq = runningSession.seq @@ -2138,31 +2138,25 @@ class AidenBotSessionControllerTest { controller.handle(snapshotOf(runningSession.copy(seq = seq, queued = queued))) } - // A second snapshot agrees: it goes. assertTrue(controller.send("Plan lunch")) snapshot() - assertEquals("one look may miss it", listOf("Plan lunch"), controller.state.value.outgoing.map { it.text }) - snapshot() - assertTrue(controller.state.value.outbox.isEmpty()) - - // Or the grace period runs out. - assertTrue(controller.send("And dinner")) 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("And dinner"), controller.state.value.outgoing.map { it.text }) + assertEquals(listOf("Plan lunch"), controller.state.value.outgoing.map { it.text }) advanceTimeBy(2) runCurrent() assertTrue(controller.state.value.outbox.isEmpty()) - // A full queue can't show it: it stays. - assertTrue(controller.send("And breakfast")) - val full = List(AidenBotQueuedMessage.MAX_COUNT) { AidenBotQueuedMessage("q$it", AidenStreamInputMode.QUEUE, "Other $it") } - snapshot(full) - snapshot(full) + // 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(listOf("And breakfast"), controller.state.value.outgoing.map { it.text }) + 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. From 1672ed9b38897d73e83e0192fa34c7e72accadb0 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 18:28:40 -0400 Subject: [PATCH 44/83] Docs: Android final-wave outcomes; memory note placement Co-Authored-By: Claude Opus 5.5 --- .memory/bot-chat-activity.md | 6 +++--- .../specs/2026-10-14-bot-chat-activity-design.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.memory/bot-chat-activity.md b/.memory/bot-chat-activity.md index 5943249ec..e5d25642a 100644 --- a/.memory/bot-chat-activity.md +++ b/.memory/bot-chat-activity.md @@ -56,10 +56,10 @@ Plan: docs/superpowers/plans/2026-10-14-bot-chat-activity-d-phones.md. Spec §11 - 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). +- 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 (any rejection is still recorded as `internal_error` and replayed). 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. +- 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. @@ -87,7 +87,7 @@ If `main` has moved past 29, change every `30` D introduced to the next free rev - 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. -- TODO(controller): record the Android final-wave outcomes (FR-I2, FR-I3, FR-m2 to FR-m8, R15, R21 completion, polish, the raw-null guard) from `final-fix-report-android.md`, which did not exist when this was written, and drop what they closed from the parked list below. +- 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) - Android items the final wave did not close (see the TODO above); the iOS Files sheet `ProgressView`. 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 edb3d89c6..b3bbe512d 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 @@ -457,7 +457,7 @@ Every new test file is registered in the matching `package.json` script and `scr - **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. - - TODO(controller): confirm the Android R21 outcome from `final-fix-report-android.md`, which did not exist when this was written. + - 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. @@ -479,7 +479,7 @@ Every new test file is registered in the matching `package.json` script and `scr - 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). - Open on iOS: the Files sheet in `AidenBotChatToolsView` still shows a `ProgressView`; no manual VoiceOver pass or live-Mac run. - - TODO(controller): add the Android final-wave outcomes from `final-fix-report-android.md` (not yet written when this was) and trim the Android items below that they closed. + - 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).** They are follow-ups unless the Android report closes them: - Desktop: overlapping Stops where one fails briefly clear "Stopping" (cosmetic). - Fixtures: the Android raw-null guard for the null-literal samples; a whitespace-only `app` label parity note. From bbd13274ed6e73e094138d8eab84981244f318de Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 18:41:50 -0400 Subject: [PATCH 45/83] Remote Bots: a model that can't run is a 409 refusal from state, not a 500 Co-Authored-By: Claude Opus 5.5 --- docs/aiden-remote-api-v1.md | 2 +- main/services/aiden-remote-bot-session.ts | 1 + main/services/aiden-remote-router.test.ts | 11 ++++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index f5ec5cb12..a0236de63 100644 --- a/docs/aiden-remote-api-v1.md +++ b/docs/aiden-remote-api-v1.md @@ -647,7 +647,7 @@ Phones show what a Bot's running turn is doing ("Reading", "Running a command"), - 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, Bots held by another Aiden process or a paused turn; `500 internal_error` for a model that can't run right now) 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. +**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. diff --git a/main/services/aiden-remote-bot-session.ts b/main/services/aiden-remote-bot-session.ts index b07daf121..de490717b 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -1010,6 +1010,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); diff --git a/main/services/aiden-remote-router.test.ts b/main/services/aiden-remote-router.test.ts index d9b4a2a50..85ba9c703 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -5281,6 +5281,7 @@ test("a send the paused Bot refused is not recorded: once resumed, the same key persisted = snapshot as { entries: unknown[] }; }, }); + let modelBroken = true; let paused = true; const sent: string[] = []; let state: BotSessionState = { kind: "interrupted", submissionId: "16" }; @@ -5288,7 +5289,8 @@ test("a send the paused Bot refused is not recorded: once resumed, the same key bots, runtime: async () => ({ send: async (_botId, input) => { - // The session's own refusal from state, before anything is written. + // 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."); @@ -5315,6 +5317,13 @@ test("a send the paused Bot refused is not recorded: once resumed, the same key }); 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"); From e0202df7a7214d5c30b898a9dfd286f5a0fe0b3f Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 18:55:06 -0400 Subject: [PATCH 46/83] Android Bots: a restore that lands after an access-loss purge is dropped The outbox restore read the disk before a 403 purged the chat, then merged its result after the purge. That put the unsent text back in the outbox and on disk while accessLost was true. Each access-loss purge now bumps a generation, cancels the restore, and marks the chat restored. The restore continuation drops a result whose generation changed. The store's purge already leaves the chat read and empty, and a store test pins that. Tests: a controlled-IO ordering reproduces the PR #479 finding (failed before the fix), and a store test checks that a restore queued before a purge reads and writes nothing. Co-Authored-By: Claude Opus 5.5 --- .../bots/AidenBotSessionController.kt | 13 ++++ .../bots/AidenBotSessionControllerTest.kt | 67 +++++++++++++++++++ 2 files changed, 80 insertions(+) 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 56a6b7515..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 @@ -437,6 +437,8 @@ class AidenBotSessionController( 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() @@ -521,6 +523,7 @@ class AidenBotSessionController( 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) @@ -530,6 +533,12 @@ class AidenBotSessionController( // 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 } @@ -1138,6 +1147,10 @@ class AidenBotSessionController( /** 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) } 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 a97b57805..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 @@ -9,6 +9,7 @@ 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 @@ -1439,6 +1440,72 @@ class AidenBotSessionControllerTest { } } + // 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() From f78443bae97e058c974899531c15d8539d14e164 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 18:57:05 -0400 Subject: [PATCH 47/83] Remote Bots: cap queued and snapshot bytes under the SSE frame limit Twenty 32,000-code-point CJK queued messages made a session snapshot of about 1.9 MB, over the 1 MiB frame limit both phone SSE readers enforce, so the activity feed failed and reconnected in a loop. The queued list now keeps the oldest messages whose serialized JSON fits 256 KiB, in order, and omits the rest. Text is never cut; phones keep their own outbox copies. Snapshots are measured against the SSE frame limit less a 64 KiB margin: the partial, question, approval and queued list take that budget first, and the transcript window takes what remains (still capped at 768 KiB), leaving out entries that do not fit rather than cutting them. Documented in the revision 30 Bot chat activity section. Co-Authored-By: Claude Opus 5.5 --- docs/aiden-remote-api-v1.md | 1 + .../services/aiden-remote-bot-session.test.ts | 112 ++++++++++++++++++ main/services/aiden-remote-bot-session.ts | 60 ++++++++-- 3 files changed, 165 insertions(+), 8 deletions(-) diff --git a/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index a0236de63..0ec169c1f 100644 --- a/docs/aiden-remote-api-v1.md +++ b/docs/aiden-remote-api-v1.md @@ -643,6 +643,7 @@ Phones show what a Bot's running turn is doing ("Reading", "Running a command"), - 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.** The `queued` list's serialized JSON is at most 256 KiB. The Mac walks the waiting messages oldest first and lists them while they fit, in order; the first message that would overflow ends the list, and the rest are left out of the projection. A message is never cut to fit, so a phone matches its outbox copy by exact text and `submissionId`; the host just shows fewer queued bubbles. A snapshot or frame's serialized JSON stays inside the 1 MiB SSE frame limit less a 64 KiB margin for the SSE lines and envelope. The partial, question, approval and `queued` take that budget first, and the transcript window takes what remains, never more than 768 KiB. It keeps the newest run of entries that fits, stepping back from the newest; when older entries are left out, `hasOlder` is `true`, and a snapshot may carry no entries at all when the rest fills the budget. - 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. diff --git a/main/services/aiden-remote-bot-session.test.ts b/main/services/aiden-remote-bot-session.test.ts index 8a0f51c36..e42f7ac8d 100644 --- a/main/services/aiden-remote-bot-session.test.ts +++ b/main/services/aiden-remote-bot-session.test.ts @@ -29,9 +29,11 @@ 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, parseAidenRemoteBotSession, parseAidenRemoteBotSessionEntry, parseAidenRemoteBotSessionEvent, + parseAidenSseFrames, } from "./aiden-remote-protocol.js"; import { BOT_NOTICE_ENTRY_KIND, @@ -1279,6 +1281,8 @@ async function openAudienceStream( 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 () => { @@ -1290,6 +1294,7 @@ async function openAudienceStream( 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); } @@ -1298,6 +1303,7 @@ async function openAudienceStream( return { frames, raw, + blocks, async until(predicate: () => boolean, what: string) { const deadline = Date.now() + 10_000; while (!predicate()) { @@ -1729,6 +1735,112 @@ test("Steer and Queue reach the runtime as steer and followUp, and only the pers } }); +/** 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(); + } +}); + 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" }); diff --git a/main/services/aiden-remote-bot-session.ts b/main/services/aiden-remote-bot-session.ts index de490717b..de64a5edd 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -66,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, @@ -123,7 +124,21 @@ 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 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`). The transcript window, + * the partial, the question, the approval and the queued list all share it. + */ +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). */ @@ -440,6 +455,8 @@ export function projectBotSessionActivity( * 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>, @@ -447,11 +464,21 @@ export function projectBotSessionQueued( ): AidenRemoteBotQueuedMessage[] { const seen = new Set(); const queued: AidenRemoteBotQueuedMessage[] = []; + // Serialized size of the list so far: the brackets, plus a comma before each item after the first. + let bytes = 2; 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; + const item: AidenRemoteBotQueuedMessage = { + submissionId: message.submissionId, + mode: message.mode === "steer" ? "steer" : "queue", + text, + }; + const itemBytes = Buffer.byteLength(JSON.stringify(item), "utf8") + (queued.length > 0 ? 1 : 0); + if (bytes + itemBytes > MAX_QUEUED_JSON_BYTES) break; seen.add(message.submissionId); - queued.push({ submissionId: message.submissionId, mode: message.mode === "steer" ? "steer" : "queue", text }); + queued.push(item); + bytes += itemBytes; if (queued.length === AIDEN_REMOTE_BOT_QUEUED_MAX) break; } return queued; @@ -467,11 +494,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 }; @@ -609,19 +646,26 @@ class BotLiveProjector { this.armIdle(); } - /** The session as one device sees it. */ + /** + * The session as one device sees it. Everything but the transcript is + * measured first; the transcript window takes what the snapshot budget + * leaves, so the whole snapshot stays inside the SSE frame limit. + */ snapshot(audience: Audience): AidenRemoteBotSession { - return parseAidenRemoteBotSession({ + const head = { botId: this.botId, epoch: this.epoch, seq: this.seq, ...this.state, ...(this.partial !== undefined ? { partial: this.partial } : {}), - ...windowed(entriesFor(this.entries, audience)), + entries: [] as AidenRemoteBotSessionEntry[], + hasOlder: false, question: this.question, approval: this.approval, ...(audience.activity && this.activityDeps ? { activity: this.activity, queued: this.queued } : {}), - }); + }; + const room = SNAPSHOT_BUDGET_BYTES - Buffer.byteLength(JSON.stringify(head), "utf8"); + return parseAidenRemoteBotSession({ ...head, ...windowed(entriesFor(this.entries, audience), room) }); } /** The `snapshot` frame for the current seq (first frame of a stream, or a history rewrite). */ From dbdf9ae5b4462a1710dcff1310287815981939d0 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 19:17:13 -0400 Subject: [PATCH 48/83] iOS release policy: match the Bot chat and Advanced catalog code as it now reads Co-Authored-By: Claude Opus 5.5 --- scripts/check-ios-shipping-target.test.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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"); From 7aebccfe5916c85e4fc4dbb2dd00cc2979435270 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 19:51:29 -0400 Subject: [PATCH 49/83] Remote Bots: allocate the snapshot budget in priority order by construction A snapshot could still exceed the 1 MiB frame limit: a 600,000-byte escaped partial, four maximal questions and a full queue summed past it, and the transcript could not make up the difference because a negative budget only removed entries. The snapshot now allocates its budget in order. Identity, state, question, approval and activity ship whole (about 280 KB at their maxima). The partial ships whole or is cut from its front to its newest words on a code-point boundary, which phones accept because a partial replaces the previous text. The queued list takes at most 256 KiB and what is left, oldest first, with no text cut. The transcript takes what remains and may be empty, with hasOlder kept correct. Each frame type is bounded below the limit on its own. Co-Authored-By: Claude Opus 5.5 --- docs/aiden-remote-api-v1.md | 2 +- .../services/aiden-remote-bot-session.test.ts | 112 ++++++++++++++++ main/services/aiden-remote-bot-session.ts | 121 +++++++++++++----- 3 files changed, 205 insertions(+), 30 deletions(-) diff --git a/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index 0ec169c1f..0e6f22dfc 100644 --- a/docs/aiden-remote-api-v1.md +++ b/docs/aiden-remote-api-v1.md @@ -643,7 +643,7 @@ Phones show what a Bot's running turn is doing ("Reading", "Running a command"), - 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.** The `queued` list's serialized JSON is at most 256 KiB. The Mac walks the waiting messages oldest first and lists them while they fit, in order; the first message that would overflow ends the list, and the rest are left out of the projection. A message is never cut to fit, so a phone matches its outbox copy by exact text and `submissionId`; the host just shows fewer queued bubbles. A snapshot or frame's serialized JSON stays inside the 1 MiB SSE frame limit less a 64 KiB margin for the SSE lines and envelope. The partial, question, approval and `queued` take that budget first, and the transcript window takes what remains, never more than 768 KiB. It keeps the newest run of entries that fits, stepping back from the newest; when older entries are left out, `hasOlder` is `true`, and a snapshot may carry no entries at all when the rest fills the budget. +- **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. diff --git a/main/services/aiden-remote-bot-session.test.ts b/main/services/aiden-remote-bot-session.test.ts index e42f7ac8d..3036cfb67 100644 --- a/main/services/aiden-remote-bot-session.test.ts +++ b/main/services/aiden-remote-bot-session.test.ts @@ -20,6 +20,7 @@ import { projectBotSessionEntries, projectBotSessionState, redactRemoteError, + tailWithinJsonBytes, type AidenRemoteBotSessionRuntime, } from "./aiden-remote-bot-session.js"; import type { AskUserQuestionV1 } from "../../renderer/shared/ask-user-question.js"; @@ -30,6 +31,8 @@ import { createBotLiveProjection, createBotRunStarts } from "./bot-runtime/live- import { AIDEN_REMOTE_BOT_SESSION_MAX_ENTRIES, AIDEN_REMOTE_MAX_SSE_FRAME_BYTES, + parseAidenRemoteBotApproval, + parseAidenRemoteBotQuestion, parseAidenRemoteBotSession, parseAidenRemoteBotSessionEntry, parseAidenRemoteBotSessionEvent, @@ -1841,6 +1844,115 @@ test("a transcript near its own budget with a long partial and a full queue stil } }); +/** 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" }); diff --git a/main/services/aiden-remote-bot-session.ts b/main/services/aiden-remote-bot-session.ts index de64a5edd..f9c7da347 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -127,10 +127,11 @@ 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 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`). The transcript window, - * the partial, the question, the approval and the queued list all share it. + * 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; /** @@ -463,25 +464,61 @@ export function projectBotSessionQueued( personal: ReadonlySet, ): AidenRemoteBotQueuedMessage[] { const seen = new Set(); - const queued: AidenRemoteBotQueuedMessage[] = []; - // Serialized size of the list so far: the brackets, plus a comma before each item after the first. - let bytes = 2; + 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; - const item: AidenRemoteBotQueuedMessage = { - submissionId: message.submissionId, - mode: message.mode === "steer" ? "steer" : "queue", - text, - }; - const itemBytes = Buffer.byteLength(JSON.stringify(item), "utf8") + (queued.length > 0 ? 1 : 0); - if (bytes + itemBytes > MAX_QUEUED_JSON_BYTES) break; seen.add(message.submissionId); - queued.push(item); - bytes += itemBytes; - if (queued.length === AIDEN_REMOTE_BOT_QUEUED_MAX) break; + valid.push({ submissionId: message.submissionId, mode: message.mode === "steer" ? "steer" : "queue", text }); + if (valid.length === AIDEN_REMOTE_BOT_QUEUED_MAX) break; } - return queued; + 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 { @@ -647,25 +684,51 @@ class BotLiveProjector { } /** - * The session as one device sees it. Everything but the transcript is - * measured first; the transcript window takes what the snapshot budget - * leaves, so the whole snapshot stays inside the SSE frame limit. + * 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 head = { + 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 } : {}), - entries: [] as AidenRemoteBotSessionEntry[], - hasOlder: false, + ...(partial !== undefined ? { partial } : {}), + entries, + hasOlder, question: this.question, approval: this.approval, - ...(audience.activity && this.activityDeps ? { activity: this.activity, queued: this.queued } : {}), - }; - const room = SNAPSHOT_BUDGET_BYTES - Buffer.byteLength(JSON.stringify(head), "utf8"); - return parseAidenRemoteBotSession({ ...head, ...windowed(entriesFor(this.entries, audience), room) }); + ...(showActivity ? { activity: this.activity, queued } : {}), + }); } /** The `snapshot` frame for the current seq (first frame of a stream, or a history rewrite). */ From 65e00968bb612d9713ad6259322334d4dbcb2c91 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 20:13:41 -0400 Subject: [PATCH 50/83] iOS Bots: route outbox failures through the typed diagnostics wrapper The outbox store logged through its own Logger, which the diagnostics policy forbids outside AidenOnTheGoApp.swift. Each site now records a cache failure with a fixed outcome and code, with no counts or text. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotSessionModel.swift | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index fe1e97ab8..f624c8acf 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -1,7 +1,6 @@ import CryptoKit import Foundation import Observation -import os /// Host feature tokens added in contract revision 25. enum AidenBotHostFeature { @@ -461,7 +460,6 @@ actor AidenBotOutboxStore { private var revokedDevices: Set = [] /// Chats whose unreadable file couldn't be moved aside: it is never written over. private var stuckFiles: Set = [] - private static let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "sbtbiswas.AidenOnTheGo", category: "BotOutbox") private struct Record: Codable { let key: UUID @@ -535,20 +533,20 @@ actor AidenBotOutboxStore { return ([], false) } catch { // There, but it can't be opened: it is set aside unread, never written over. - Self.log.error("event=outbox_unopenable outcome=set_aside") + 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. - Self.log.error("event=outbox_oversized outcome=set_aside") + 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 { - Self.log.error("event=outbox_unreadable outcome=set_aside") + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .degraded, code: .corruptData) setAside(url, for: key, keepingOriginal: false) return ([], true) } @@ -556,7 +554,7 @@ actor AidenBotOutboxStore { let lostSome = readable.count != envelope.messages.count if lostSome { // The readable ones are written back; the file as it was is kept beside them. - Self.log.error("event=outbox_record_unreadable outcome=set_aside count=\(envelope.messages.count - readable.count, privacy: .public)") + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .degraded, code: .corruptData) setAside(url, for: key, keepingOriginal: true) } let cutoff = now().addingTimeInterval(-Self.lifetime) @@ -603,7 +601,7 @@ actor AidenBotOutboxStore { try fileManager.moveItem(at: url, to: aside) } } catch { - Self.log.error("event=outbox_set_aside outcome=failed") + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .failed, code: .unknown) if !keepingOriginal { stuckFiles.insert(key) } } } @@ -651,7 +649,7 @@ actor AidenBotOutboxStore { ) } guard !stuckFiles.contains(key) else { - Self.log.error("event=outbox_write outcome=skipped_unread_file") + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .failed, code: .unknown) return } if records.isEmpty { @@ -661,7 +659,7 @@ actor AidenBotOutboxStore { do { try write(records, for: key) } catch { - Self.log.error("event=outbox_write outcome=failed") + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .failed, code: .unknown) } } @@ -687,7 +685,7 @@ actor AidenBotOutboxStore { )) guard data.count <= Self.maxBytes else { // The chat refuses messages past this budget, so this keeps the last good file. - Self.log.error("event=outbox_write outcome=over_budget") + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .failed, code: .unknown) return } let url = fileURL(key) From cc7ae5c6baecf3a57f03d789a648e0e85e7534d8 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 20:32:07 -0400 Subject: [PATCH 51/83] iOS Bots: a send before the outbox restore never replaces the last launch's file A message sent before the first restore wrote an outbox holding only itself, which could reach the store before the restore read the file and wipe the previous launch's unsent messages. Disk writes now wait for the restore; it merges the file with any earlier sends by key and writes the result once. A newer chat that took the outbox meanwhile still owns the write. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotSessionModel.swift | 48 +++++++++++++------ .../AidenBotSessionTests.swift | 36 +++++++++++++- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index f624c8acf..ae60b16df 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -893,6 +893,8 @@ final class AidenBotSessionModel { @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 @ObservationIgnored private var released = false @ObservationIgnored private let requestLimit: Duration @ObservationIgnored private let now: () -> Date @@ -1321,27 +1323,45 @@ final class AidenBotSessionModel { 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. private func restoreOutbox() async { - guard let cacheKey, let outboxStore else { return } - if restoreTask == nil { - restoreTask = Task { [weak self] in - let (stored, unreadable) = await outboxStore.restore(cacheKey) - guard let self else { return } - if unreadable { self.restoreNotice = AidenBotSessionCopy.outboxUnrestorable } - guard !stored.isEmpty else { return } - 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 { - self.outbox = (added + self.outbox).sorted { $0.createdAt < $1.createdAt } - } + 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, let cacheKey, let outboxStore else { return } + restoreTask = Task { [weak self] in + let (stored, unreadable) = await outboxStore.restore(cacheKey) + guard let self 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) } - await restoreTask?.value } /// The Mac is reachable again: deferred messages get another try. diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index b03b8e80b..d32368d8e 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -2510,6 +2510,39 @@ final class AidenBotSessionOptimisticTests: XCTestCase { XCTAssertEqual(values.isExcludedFromBackup, true) } + func testASendBeforeTheRestoreKeepsTheLastLaunchesUnsentMessagesOnDiskAndInMemory() async throws { + let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) + let (store, _) = temporaryStore() + // The last launch left one unsent message on disk. + store.submit(.save([AidenBotOutgoingMessage( + key: UUID(), text: "Left last launch", mode: nil, enqueuedWhileRunning: false, + retryOf: nil, createdAt: Date(), status: .failed + )]), for: key) + try await stored(store, key, count: 1) + + // A new launch: the composer takes a message before the restore has read the file. + let transport = FakeBotSessionTransport() + let relaunched = model(transport, key: key, cache: AidenBotSessionCache(), store: store) + _ = await relaunched.send("Plan my week.") + try await Task.sleep(for: .milliseconds(100)) + // The send's write has had time to land; it must not have replaced the previous launch's file. + let beforeLoad = await store.load(key) + XCTAssertTrue( + beforeLoad.map(\.text).contains("Left last launch"), + "a write before the restore would replace the previous launch's file" + ) + + transport.queueSession(.success(try session(.idle))) + await relaunched.load() + XCTAssertEqual( + relaunched.outbox.map(\.text), ["Left last launch", "Plan my week."], + "both messages are in memory, oldest first" + ) + try await stored(store, key, count: 2) + let onDisk = await store.load(key) + XCTAssertEqual(onDisk.map(\.text), ["Left last launch", "Plan my week."], "both survive a relaunch") + } + func testAMessageStillUnsentAfterADaySaysCouldntSendInsteadOfGoingOutLate() async throws { let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) let (store, root) = temporaryStore() @@ -2612,10 +2645,11 @@ final class AidenBotSessionOptimisticTests: XCTestCase { _ = await oldSend.value old.release() - XCTAssertEqual(cache.adoptOutbox(for: key, owner: ObjectIdentifier(self)).map(\.text), ["First", "Second"]) + // The new chat writes once it has read the file; adopting its outbox below takes the chat from it. try await stored(store, key, count: 2) let onDisk = await store.load(key) XCTAssertEqual(onDisk.map(\.text), ["First", "Second"]) + XCTAssertEqual(cache.adoptOutbox(for: key, owner: ObjectIdentifier(self)).map(\.text), ["First", "Second"]) } func testLosingAccessPurgesTheOutboxFromMemoryAndDisk() async throws { From 9a2feca96a6f61e138378f0a97f8ba4f91b4af16 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 20:32:16 -0400 Subject: [PATCH 52/83] iOS Bots: Try Again replays the frozen Create through a catalog change Each retry rebuilt the request from the latest catalog before matching the retained attempt, and the request carries the catalog revision, so an unrelated catalog change after a lost answer minted a new Idempotency-Key and could make a second Bot. A retry with the same answers now reuses the attempt's request and key before any catalog is read; only a changed answer or a definite refusal starts a new one. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotCreateView.swift | 33 +++++++++++++-- .../AidenBotHomeProfileTests.swift | 41 ++++++++++++++++--- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift index 0ed8a3cec..a0494d729 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift @@ -52,7 +52,11 @@ final class AidenBotCreateFlow { private struct Attempt { let scope: String + /// What the person asked for; a retry is the same attempt while this is unchanged. + let input: AidenBotCreateInput let request: AidenBotCreateRequest + /// The model the request was built from, for the chat step. + let draft: AidenBotEditorDraft? let key: UUID let chatKey: UUID var created: AidenBotDetail? @@ -88,12 +92,20 @@ final class AidenBotCreateFlow { return } guard operations.isCurrent() else { return } - let (request, draft) = try await resolve(input, operations: operations) var attempt: Attempt - if let retained, retained.scope == operations.scope, retained.request == request { + if let retained, retained.scope == operations.scope, Self.isSameAsk(retained, input) { + // The same ask as a failed try: its frozen request and key, whatever the catalog says now. attempt = retained } else { - attempt = Attempt(scope: operations.scope, request: request, key: UUID(), chatKey: UUID()) + let (request, draft) = try await resolve(input, operations: operations) + attempt = Attempt( + scope: operations.scope, + input: input, + request: request, + draft: draft, + key: UUID(), + chatKey: UUID() + ) } retained = attempt sent = true @@ -106,7 +118,7 @@ final class AidenBotCreateFlow { retained = attempt } guard operations.isCurrent() else { return } - if !operations.usesDurableSession, let draft { + if !operations.usesDurableSession, let draft = attempt.draft { let chat = try AidenBotChatCreateRequest( providerId: draft.customAccess.providerID, modelId: draft.customAccess.modelID @@ -165,6 +177,19 @@ final class AidenBotCreateFlow { 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.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() } diff --git a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift index 5ac4ea4c2..713c8d336 100644 --- a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift @@ -492,10 +492,13 @@ final class AidenBotInstantEditTests: XCTestCase { return try AidenRemoteJSONDecoder.decode(AidenBotDetail.self, from: JSONSerialization.data(withJSONObject: object)) } - private func catalog() throws -> AidenBotCapabilityCatalog { - try AidenRemoteJSONDecoder.decode( + /// 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: try XCTUnwrap(try shared()["botCapabilityCatalog"])) + from: JSONSerialization.data(withJSONObject: object) ) } @@ -657,9 +660,10 @@ final class AidenBotInstantEditTests: XCTestCase { private func createOperations( _ mac: CreateMac, connected: Bool = true, - durable: Bool = true + durable: Bool = true, + catalogRevision: String? = nil ) throws -> AidenBotCreateOperations { - let catalog = try catalog() + let catalog = try catalog(revision: catalogRevision) return AidenBotCreateOperations( scope: "mac_a|device_1", usesDurableSession: durable, @@ -727,6 +731,33 @@ final class AidenBotInstantEditTests: XCTestCase { XCTAssertNotEqual(mac.creates[2].key, mac.creates[3].key) } + func testTryAgainAfterALostCreateReplaysTheSameRequestThroughACatalogChange() async throws { + let mac = CreateMac() + mac.createErrors = [URLError(.networkConnectionLost)] + let flow = AidenBotCreateFlow() + 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)] From 25574c1ae8f4708c24a37573cbed3d8d1f071dd1 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 20:32:16 -0400 Subject: [PATCH 53/83] iOS Bots: Chat activity picks stay with the pairing they were made on The save and refresh resolved the active pairing when they ran, so a pick waiting for a connection could go to a Mac switched to meanwhile, and a late answer from the old Mac was cached under the new pairing. The page now captures the request context when its model is built: requests use it, a changed pairing gives up silently and rolls the pick back, and results from another pairing are never cached or reported. This mirrors Android's samePairing() fence. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotAdvancedView.swift | 51 ++++++++++++++---- .../AidenBotSessionTests.swift | 53 ++++++++++++++++++- 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift index 7c821645a..100a3ba1c 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift @@ -338,10 +338,16 @@ struct AidenBotAdvancedView: View { // 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 - let client = try coordinator.remoteClient(for: try coordinator.requestContext()) + guard let context else { throw AidenRemoteClientError.installationChanged } + let client = try coordinator.remoteClient(for: context) return try await client.updateBotIdentity( id: botID, revision: revision, @@ -349,24 +355,28 @@ struct AidenBotAdvancedView: View { ) }, refresh: { - let client = try coordinator.remoteClient(for: try coordinator.requestContext()) + 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) + cacheDetail(saved, for: context) report.saved(saved, reportBehind: onSaved) }, - waitForConnection: { hold in await aidenBotWaitForConnection(coordinator, hold: hold) } + waitForConnection: { hold in await aidenBotWaitForConnection(coordinator, hold: hold) }, + isCurrent: isCurrent ) } isShowingChatActivity = true } - private func cacheDetail(_ detail: AidenBotDetail) { - guard let context = try? coordinator.requestContext() else { return } + 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( @@ -1009,17 +1019,21 @@ final class AidenBotChatActivityModel { @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. + /// 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) ) { @@ -1031,6 +1045,7 @@ final class AidenBotChatActivityModel { self.refresh = refresh self.onSaved = onSaved self.waitForConnection = waitForConnection + self.isCurrent = isCurrent self.connectionHold = connectionHold self.requestLimit = requestLimit } @@ -1062,18 +1077,24 @@ final class AidenBotChatActivityModel { /// late shows as saved with no error, and anything else returns to what the Mac holds. private func send(_ target: AidenBotChatActivity) async -> Bool { do { - if let waitForConnection, await waitForConnection(connectionHold) == false { - throw AidenBotChatActivityUnreachable() + 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() } pending = nil if error is CancellationError { value = saved @@ -1100,10 +1121,20 @@ final class AidenBotChatActivityModel { } } + /// 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() }) else { return nil } + guard let fresh = try? await aidenBotWithin(requestLimit, { try await refresh.call() }), + isCurrent() else { return nil } revision = fresh.revision saved = fresh.chatActivity ?? .standard onSaved(fresh) diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index d32368d8e..91af032fd 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -3658,15 +3658,66 @@ final class AidenBotChatActivityTests: XCTestCase { 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, connectionHold: connectionHold, requestLimit: requestLimit + 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 + var holding = false + var letGo = false + var saved: [AidenBotDetail] = [] + var refreshes = 0 + let model = try model(save: { next, _ in + holding = true + while !letGo { try await Task.sleep(for: .milliseconds(5)) } + 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 { holding } + paired = false + letGo = true + 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] = [] From 573298e5553e7b0396183c48619b70cd7a9a453f Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 21:01:17 -0400 Subject: [PATCH 54/83] iOS Bots test: seed the last launch's outbox through a model, not a fileprivate initializer Co-Authored-By: Claude Opus 5.5 --- ios/AidenOnTheGoTests/AidenBotSessionTests.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index 91af032fd..7a7b35c6b 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -2513,12 +2513,11 @@ final class AidenBotSessionOptimisticTests: XCTestCase { func testASendBeforeTheRestoreKeepsTheLastLaunchesUnsentMessagesOnDiskAndInMemory() async throws { let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) let (store, _) = temporaryStore() - // The last launch left one unsent message on disk. - store.submit(.save([AidenBotOutgoingMessage( - key: UUID(), text: "Left last launch", mode: nil, enqueuedWhileRunning: false, - retryOf: nil, createdAt: Date(), status: .failed - )]), for: key) + // The last launch left one unsent message on disk (never connected, so never sent). + let lastLaunch = model(FakeBotSessionTransport(), key: key, cache: AidenBotSessionCache(), store: store) + _ = await lastLaunch.send("Left last launch") try await stored(store, key, count: 1) + lastLaunch.release() // A new launch: the composer takes a message before the restore has read the file. let transport = FakeBotSessionTransport() From 7744dd31b13cf17a6e1dc9e7ed74e187033ccb4c Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 21:16:51 -0400 Subject: [PATCH 55/83] iOS Bots: a restore that lands after an access-loss purge is dropped Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotSessionModel.swift | 27 +++++++++++++--- .../AidenBotSessionTests.swift | 32 ++++++++++++++++++- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index ae60b16df..bedbef369 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -523,7 +523,18 @@ actor AidenBotOutboxStore { /// 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) -> (messages: [AidenBotOutgoingMessage], unreadable: Bool) { + 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 @@ -895,6 +906,8 @@ final class AidenBotSessionModel { @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 @@ -1338,7 +1351,7 @@ final class AidenBotSessionModel { } /// Adds what a previous launch left on disk, once, before the first snapshot sends it. - private func restoreOutbox() async { + func restoreOutbox() async { guard outboxStore != nil else { return } startRestore() await restoreTask?.value @@ -1347,10 +1360,12 @@ final class AidenBotSessionModel { /// 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, let cacheKey, let outboxStore else { return } + 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) - guard let self else { return } + // 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() @@ -1541,11 +1556,15 @@ final class AidenBotSessionModel { 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." } } diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index 7a7b35c6b..a0e8e6b90 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -2542,6 +2542,36 @@ final class AidenBotSessionOptimisticTests: XCTestCase { XCTAssertEqual(onDisk.map(\.text), ["Left last launch", "Plan my week."], "both survive a relaunch") } + func testARestoreThatLandsAfterAnAccessLossPurgeIsDropped() async throws { + let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) + let (store, _) = temporaryStore() + // The last launch left one unsent message on disk. + let lastLaunch = model(FakeBotSessionTransport(), key: key, cache: AidenBotSessionCache(), store: store) + _ = await lastLaunch.send("Left last launch") + try await stored(store, key, count: 1) + lastLaunch.release() + + // A new launch reads the file, and is held before the read reaches the chat. + let gate = TestGate() + gate.hold() + await store.setAfterRead { await gate.pass() } + let relaunched = model(FakeBotSessionTransport(), key: key, cache: AidenBotSessionCache(), store: store) + _ = await relaunched.send("Plan my week.") + try await waitUntil({ gate.isHolding }, what: "the restore to read the file") + + // Access is lost while the restore is held. + let blocked = try session(.interrupted, blocked: .accessChanged) + _ = await relaunched.apply(AidenBotSessionEvent(botId: botID, epoch: "epoch_a", seq: 4, kind: .snapshot(blocked))) + XCTAssertEqual(relaunched.outbox, []) + + gate.releaseAll() + await relaunched.restoreOutbox() + XCTAssertEqual(relaunched.outbox, [], "the restore lands after the purge and brings nothing back") + XCTAssertEqual(relaunched.errorMessage, "That message wasn’t sent. Please try again.") + XCTAssertTrue(relaunched.isAccessBlocked) + try await stored(store, key, count: 0) + } + func testAMessageStillUnsentAfterADaySaysCouldntSendInsteadOfGoingOutLate() async throws { let key = AidenBotSessionKey(instanceId: "mac_a", deviceId: "device_1", botID: botID) let (store, root) = temporaryStore() @@ -4107,5 +4137,5 @@ final class AidenBotChatPolishTests: XCTestCase { extension AidenBotOutboxStore { /// Test seam: what a chat would restore, without the unreadable flag. - func load(_ key: AidenBotSessionKey) -> [AidenBotOutgoingMessage] { restore(key).messages } + func load(_ key: AidenBotSessionKey) async -> [AidenBotOutgoingMessage] { await restore(key).messages } } From 3d3cbc9597db6a924b60c5316cf83ece5c919c4e Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 23:17:26 -0400 Subject: [PATCH 56/83] Test harness: contain a late EPIPE when the worktree editor exits before reading its input The editor may refuse a path and exit before stdin is read; the test asserts its exit status and output, so the write error is expected, not a failure. Co-Authored-By: Claude Opus 5.5 --- scripts/worktree-file-io.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) 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); From ea29ffe2fb106a2826702a563e9acf2e07331240 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 23:28:06 -0400 Subject: [PATCH 57/83] Android Bots: refuse the fixture's null literals (app, queued, label with submissionId) The strict wire JSON read an optional key sent as null as absent. The activity, session and message decoders now refuse app: null, queued: null and a null label or submissionId, as TypeScript and iOS do, checked against every botActivityPrivacy.rejectedNullLiterals group. Co-Authored-By: Claude Opus 5.5 --- .../AidenOnTheGo/models/AidenBotSession.kt | 43 ++++++++++++++++++- .../AidenBotActivityContractTest.kt | 27 ++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) 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 09cf9cfd7..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,7 +2,9 @@ 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 @@ -19,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 @@ -41,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 { @@ -165,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, @@ -308,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, @@ -451,7 +480,9 @@ data class AidenBotQuestionAnswerReceipt(val waitId: String) { * 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. */ -@Serializable +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = AidenBotSessionActivitySerializer::class) data class AidenBotSessionActivity( val phase: String, val app: String? = null, @@ -472,6 +503,12 @@ data class AidenBotSessionActivity( } } +/** `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( @@ -568,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. diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt index 2ed901783..f474fc7e3 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotActivityContractTest.kt @@ -171,6 +171,33 @@ class AidenBotActivityContractTest { ) } + // 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() { From 6352bfbad0a523f8b76adb9853727cf3d9fc35ae Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 23:30:11 -0400 Subject: [PATCH 58/83] Android Bots: unconfirmed edits are shown, never written to the Bot cache's disk Profile, Instructions and Advanced edits went through putBotDetail while still saving, so a process that died mid-save came back showing them as saved, the next read silently reverted them and a later patch dropped fields. The cache now has showBotDetail (memory only) beside putBotDetail; the save host shows optimistic Bots and rollbacks and keeps only what the Mac confirmed, as iOS does. Co-Authored-By: Claude Opus 5.5 --- .../features/bots/AidenBotAdvancedScreen.kt | 7 +- .../features/bots/AidenBotOptimisticSaves.kt | 21 +++- .../features/bots/AidenBotProfileScreen.kt | 19 ++-- .../features/remote/AidenRemoteCoordinator.kt | 9 ++ .../AidenOnTheGo/persistence/AidenBotCache.kt | 11 ++ .../bots/AidenBotOptimisticSavesTest.kt | 107 +++++++++++++++++- 6 files changed, 157 insertions(+), 17 deletions(-) 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 47af3f14e..36dca1899 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 @@ -169,7 +169,8 @@ fun AidenBotAdvancedDraft.optimistic(bot: AidenBotDetail, catalog: AidenBotCapab * 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]. + * 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, @@ -185,7 +186,7 @@ fun aidenBotSaveAdvancedOptimistically( return } val optimistic = draft.optimistic(bot, catalog) - host.cache(optimistic) + host.show(optimistic) show(optimistic) host.launch { try { @@ -195,7 +196,7 @@ fun aidenBotSaveAdvancedOptimistically( } catch (error: CancellationException) { throw error } catch (error: Exception) { - if (host.cached(bot.id)?.let { it == optimistic } != false) host.cache(bot) + if (host.cached(bot.id)?.let { it == optimistic } != false) host.show(bot) settled(bot) failed(aidenBotSaveFailure(error)) } 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 index 38aef5d87..0ce73e830 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSaves.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSaves.kt @@ -21,7 +21,8 @@ import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteClientException /** * 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 one pinned place a saved Bot is cached. + * 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. */ @@ -29,6 +30,9 @@ class AidenBotSaveHost( /** 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 } @@ -45,6 +49,7 @@ fun AidenRemoteCoordinator.botSaveHost(): AidenBotSaveHost = pairingMemo("bot-sa client = { if (samePairing()) client.value else null }, waitForConnection = { samePairing() && aidenBotAwaitConnected(connectionState, client) && samePairing() }, launch = { block -> launchDetached(block) }, + show = pinnedBotShow(), cache = pinnedBotCache(), cached = { id -> if (samePairing()) botCache.getBotDetail(id) else null } ) @@ -135,7 +140,7 @@ class AidenBotIdentityEditor( 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): the cache follows it, so every screen shows it at once. */ + /** 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, @@ -232,8 +237,9 @@ class AidenBotIdentityEditor( } /** - * An editor that saves through the Mac in [host]'s pairing. The pinned cache holds what it - * shows, so the change appears on every screen at once and goes back with a rollback. + * 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, @@ -243,8 +249,11 @@ fun aidenBotIdentityEditor( bot, save = { saved, patch -> host.connected().updateBotIdentity(saved.id, saved.revision, patch) }, refresh = { host.connected().bot(bot.id) }, - onSaved = onSaved, - onShown = host.cache, + onSaved = { confirmed -> + host.cache(confirmed) + onSaved(confirmed) + }, + onShown = host.show, waitForConnection = host.waitForConnection ) 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 e9e751178..9a9bd695a 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 @@ -145,12 +145,16 @@ fun AidenBotProfileScreen( 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 - // Pinned: a Bot from another pairing's save never lands in this one's cache. - saveHost.cache(detail) + if (confirmed) saveHost.cache(detail) else saveHost.show(detail) editor?.adopt(detail) onBotMutated() } @@ -258,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) } } } } @@ -391,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) } } } } @@ -501,11 +505,12 @@ fun AidenBotProfileScreen( 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, - onSaveSettled = ::accept, + // 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() }, 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 8cc161d4d..00b7bb43f 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 @@ -170,6 +170,15 @@ class AidenRemoteCoordinator( 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. 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/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt index 833e07e6e..f2e9d4854 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt @@ -1,22 +1,37 @@ 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.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.assertNull 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.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 @@ -26,6 +41,9 @@ 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")) @@ -235,6 +253,7 @@ class AidenBotOptimisticSavesTest { 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() @@ -242,7 +261,8 @@ class AidenBotOptimisticSavesTest { client = { null }, waitForConnection = { false }, launch = { block -> launch { block() } }, - cache = { cache[it.id] = it }, + 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 }) @@ -255,6 +275,7 @@ class AidenBotOptimisticSavesTest { 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 @@ -266,6 +287,7 @@ class AidenBotOptimisticSavesTest { client = { null }, waitForConnection = { gate.await() }, launch = { block -> launch { block() } }, + show = { cache[it.id] = it }, cache = { cache[it.id] = it }, cached = { cache[it] } ) @@ -303,4 +325,87 @@ class AidenBotOptimisticSavesTest { assertTrue(attempts.replays(draft)) assertEquals("the Mac may have made it", first.key, attempts.begin(draft, catalog)!!.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() + } + } } From 9685204a94b0546752036111790fc917b6d2501e Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 23:34:56 -0400 Subject: [PATCH 59/83] iOS Bots: a New Bot cut off by a relaunch comes back with Try Again AidenBotCreateFlow lived only in Bots home's @State, so a relaunch during a create lost the answers and the retained Idempotency-Key without a word. The ask is now on disk per pairing before anything goes out, beside the pairing's outboxes in AidenBotOutboxStore (create.json), so it shares their rules: completeUntilFirstUserAuthentication, excluded from backup, keyed by instance and deviceId, removed with an unpair's purge and never rewritten after it, and dropped on 401/403 or a lost credential. A Bot the Mac already made is the Mac's after 24 hours; one never made always comes back. On relaunch Bots home shows it as not made. When the request was built (it may have reached the Mac), Try Again sends the frozen request on the same key, so the Mac replays its one Bot; with nothing sent, Try Again reopens New Bot as it was. OK, or closing the reopened New Bot, lets it go. A create still being sent in this launch is never shown as cut off. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotCreateView.swift | 221 +++++++++++++++--- .../Features/Bots/AidenBotSessionModel.swift | 80 ++++++- .../Features/Bots/AidenBotSettingsDraft.swift | 4 +- .../Features/Bots/AidenBotsHomeView.swift | 32 ++- .../AidenBotHomeProfileTests.swift | 161 ++++++++++++- 5 files changed, 447 insertions(+), 51 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift index a0494d729..385337ca0 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift @@ -2,17 +2,40 @@ import Observation import SwiftUI /// What New Bot was asked for, kept so a failed create can be tried again as it was. -struct AidenBotCreateInput: Equatable { +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; the 24-hour clock starts here. + let createdAt: Date +} + /// The requests one Bot create makes; the remote client provides them, tests a fake. struct AidenBotCreateOperations { - /// The pairing the create is for, so a retry matches only its own attempt. - var scope: String + /// 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 @@ -24,6 +47,8 @@ struct AidenBotCreateOperations { 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 @@ -31,6 +56,10 @@ struct AidenBotCreateOperations { /// 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. @MainActor @Observable final class AidenBotCreateFlow { @@ -43,9 +72,16 @@ final class AidenBotCreateFlow { struct Failure: Equatable { let message: String let 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 = "" } 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." private(set) var pending: Pending? var failure: Failure? @@ -54,95 +90,220 @@ final class AidenBotCreateFlow { let scope: String /// What the person asked for; a retry is the same attempt while this is unchanged. let input: AidenBotCreateInput - let request: AidenBotCreateRequest - /// The model the request was built from, for the chat step. - let draft: AidenBotEditorDraft? - let key: UUID - let chatKey: UUID - var created: AidenBotDetail? + var frozen: AidenBotPendingCreate.Frozen + let createdAt: Date } @ObservationIgnored private var retained: 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. + @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 = [] + /// 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)) { + 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 } - /// Makes the Bot; returns once it exists on the Mac (and `onCreated` ran) or failed. + nonisolated static func scope(instanceId: String, deviceId: String) -> String { "\(instanceId)|\(deviceId)" } + + /// Makes the Bot; returns once it exists on the Mac (and `onCreated` ran) or failed, + /// with what waits on disk written. func submit( _ input: AidenBotCreateInput, operations: AidenBotCreateOperations, onCreated: @MainActor (AidenBotDetail) async -> Void ) async { guard pending == nil else { return } + await make(input, operations: operations, onCreated: onCreated) + await writes?.value + } + + private func make( + _ input: AidenBotCreateInput, + operations: AidenBotCreateOperations, + onCreated: @MainActor (AidenBotDetail) async -> Void + ) async { + let scope = operations.scope failure = nil pending = Pending( id: UUID(), name: input.name.trimmingCharacters(in: .whitespacesAndNewlines), purpose: input.purpose.trimmingCharacters(in: .whitespacesAndNewlines) ) - defer { pending = nil } + 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. + let replay = retained.flatMap { $0.scope == scope && Self.isSameAsk($0, input) ? $0 : nil } + // On disk before anything goes out, so a relaunch never loses it. + await persist( + AidenBotPendingCreate(input: input, frozen: replay?.frozen, createdAt: replay?.createdAt ?? now()), + operations + ).value var sent = false do { guard await operations.waitForConnection(connectionHold) else { - failure = Failure(message: Self.unreachable, input: input) + failure = makeFailure(Self.unreachable, input, operations) return } guard operations.isCurrent() else { return } var attempt: Attempt - if let retained, retained.scope == operations.scope, Self.isSameAsk(retained, input) { - // The same ask as a failed try: its frozen request and key, whatever the catalog says now. - attempt = retained + if let replay { + attempt = replay } else { let (request, draft) = try await resolve(input, operations: operations) attempt = Attempt( - scope: operations.scope, + scope: scope, input: input, - request: request, - draft: draft, - key: UUID(), - chatKey: UUID() + frozen: AidenBotPendingCreate.Frozen(request: request, draft: draft, key: UUID(), chatKey: UUID()), + createdAt: now() ) + await persist(record(attempt), operations).value } retained = attempt sent = true let created: AidenBotDetail - if let made = attempt.created { + if let made = attempt.frozen.created { created = made } else { - created = try await bounded { try await operations.create(attempt.request, attempt.key) } - attempt.created = created + let frozen = attempt.frozen + created = try await bounded { try await operations.create(frozen.request, frozen.key) } + attempt.frozen.created = created retained = attempt + persist(record(attempt), operations) } guard operations.isCurrent() else { return } - if !operations.usesDurableSession, let draft = attempt.draft { + if !operations.usesDurableSession, let draft = attempt.frozen.draft { let chat = try AidenBotChatCreateRequest( providerId: draft.customAccess.providerID, modelId: draft.customAccess.modelID ) - try await bounded { try await operations.createChat(created.id, chat, attempt.chatKey) } + 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 = nil + persist(nil, operations) await onCreated(authoritative) } catch is CancellationError { return } catch { if await operations.handleRevocation(error) { retained = nil + persist(nil, operations) return } + if Self.isAccessLoss(error) { + // Access ended: nothing of this ask is kept on disk. + retained = nil + persist(nil, operations) + } else if sent, retained?.frozen.created == nil, !aidenBotEditorCreateFailureIsAmbiguous(error) { + // Refused before anything was made: a retry is a new request. The answers stay. + retained = nil + persist(AidenBotPendingCreate(input: input, frozen: nil, createdAt: now()), operations) + } guard operations.isCurrent() else { return } // A Bot already made, or a request that may have arrived, is tried again as it was. - if sent, retained?.created == nil, !aidenBotEditorCreateFailureIsAmbiguous(error) { - retained = nil + failure = 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. + func restore(instanceId: String, deviceId: String) async { + let scope = Self.scope(instanceId: instanceId, deviceId: deviceId) + guard let store, isFree(for: scope), restoredScopes.insert(scope).inserted else { return } + await writes?.value + guard let saved = await store.pendingCreate(instanceId: instanceId, deviceId: deviceId), + isFree(for: scope) else { return } + if let frozen = saved.frozen { + retained = Attempt(scope: scope, input: saved.input, frozen: frozen, createdAt: saved.createdAt) + } + failure = Failure( + message: Self.interrupted, + input: saved.input, + replays: saved.frozen != nil, + instanceId: instanceId, + deviceId: deviceId + ) + } + + /// 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? { + if self.failure == failure { self.failure = nil } + guard pending == nil, !Self.sending.contains(Self.scope(instanceId: failure.instanceId, deviceId: failure.deviceId)) else { return nil } + return persist(nil, instanceId: failure.instanceId, deviceId: failure.deviceId) + } + + private func isFree(for scope: String) -> Bool { + pending == nil && failure == nil && retained?.scope != scope && !Self.sending.contains(scope) + } + + private func makeFailure(_ message: String, _ input: AidenBotCreateInput, _ operations: AidenBotCreateOperations) -> Failure { + Failure(message: message, input: input, instanceId: operations.instanceId, deviceId: operations.deviceId) + } + + 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. + @discardableResult + private func persist(_ create: AidenBotPendingCreate?, instanceId: String, deviceId: String) -> Task { + let previous = writes + let store = store + let task = Task { + await previous?.value + guard let store else { return } + if let create { + await store.savePendingCreate(create, instanceId: instanceId, deviceId: deviceId) + } else { + await store.removePendingCreate(instanceId: instanceId, deviceId: deviceId) } - failure = Failure(message: aidenBotFriendlyLoadError(error), input: input) + } + 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 } } @@ -183,7 +344,7 @@ final class AidenBotCreateFlow { 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.draft, let draft = input.draft else { return false } + 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 diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index bedbef369..d68c66a8a 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -699,10 +699,13 @@ actor AidenBotOutboxStore { AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .failed, code: .unknown) return } - let url = fileURL(key) + 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) - // Unsent messages stay on this device: never in a backup. var excluded = URLResourceValues() excluded.isExcludedFromBackup = true var backupRoot = root @@ -712,11 +715,80 @@ actor AidenBotOutboxStore { } private func fileURL(_ key: AidenBotSessionKey) -> URL { - root.appending(path: digest(key.instanceId), directoryHint: .isDirectory) - .appending(path: digest(key.deviceId), directoryHint: .isDirectory) + 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. One whose Bot + /// the Mac already made is the Mac's after 24 hours and is dropped; one not made stays, + /// so 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 + } + if envelope.create.frozen?.created != nil, envelope.create.createdAt <= now().addingTimeInterval(-Self.lifetime) { + try? fileManager.removeItem(at: url) + return nil + } + return envelope.create + } + + /// Keeps the pairing's New Bot on its way; never for a pairing an unpair purged. + func savePendingCreate(_ create: AidenBotPendingCreate, instanceId: String, deviceId: String) { + knownDevices[instanceId, default: []].insert(deviceId) + guard !revokedDevices.contains(Self.pairing(AidenBotSessionKey(instanceId: instanceId, deviceId: deviceId, botID: ""))) else { + return + } + 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)) + } catch { + AidenDiagnostics.record(.cache, event: .cacheFailed, outcome: .failed, code: .unknown) + } + } + + 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() } diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSettingsDraft.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSettingsDraft.swift index eed812de0..29d339ad7 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSettingsDraft.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSettingsDraft.swift @@ -36,7 +36,7 @@ struct AidenBotCustomAccessSessionIdentity: Equatable { } } -struct AidenBotCustomAccessDraft: Equatable { +struct AidenBotCustomAccessDraft: Codable, Equatable { var providerID: String var modelID: String var fileScopeIDs: Set @@ -185,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 aca3cfda1..d58a412ac 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift @@ -278,8 +278,8 @@ struct AidenBotsHomeView: View { @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() - /// What New Bot reopens with after a failed create. - @State private var createPrefill: AidenBotCreateInput? + /// 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 { @@ -381,10 +381,16 @@ 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, initial: createPrefill) { input in + AidenBotCreateView(coordinator: coordinator, initial: createPrefill?.input) { input in startCreate(input) } case let .profile(bot): @@ -427,13 +433,18 @@ struct AidenBotsHomeView: View { presenting: createFlow.failure ) { failure in Button("Try Again") { - createPrefill = failure.input createFlow.failure = nil - presentedSheet = .create + if failure.replays { + // An earlier launch's create: the same request, on the same key. + startCreate(failure.input) + } else { + createPrefill = failure + presentedSheet = .create + } } Button("OK", role: .cancel) { createPrefill = nil - createFlow.failure = nil + createFlow.discard(failure) } } message: { failure in Text(failure.message) @@ -450,6 +461,10 @@ 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. + if loadID.isBotSurfaceActive, let installation = coordinator.installationStore.activeInstallation { + await createFlow.restore(instanceId: installation.id, deviceId: installation.deviceId) + } await load() } .task(id: searchID) { @@ -704,7 +719,8 @@ struct AidenBotsHomeView: View { createPrefill = nil let coordinator = coordinator let operations = AidenBotCreateOperations( - scope: "\(context.instanceId)|\(context.deviceId)", + 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() }, diff --git a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift index 713c8d336..b111c02be 100644 --- a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift @@ -661,11 +661,13 @@ final class AidenBotInstantEditTests: XCTestCase { _ mac: CreateMac, connected: Bool = true, durable: Bool = true, - catalogRevision: String? = nil + catalogRevision: String? = nil, + deviceId: String = "device_1" ) throws -> AidenBotCreateOperations { let catalog = try catalog(revision: catalogRevision) return AidenBotCreateOperations( - scope: "mac_a|device_1", + instanceId: "mac_a", + deviceId: deviceId, usesDurableSession: durable, waitForConnection: { _ in connected }, catalog: { catalog }, @@ -685,10 +687,31 @@ final class AidenBotInstantEditTests: XCTestCase { 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 = AidenBotCreateFlow() + let flow = createFlow() var created: [AidenBotDetail] = [] let operations = try createOperations(mac) let task = Task { await flow.submit(self.input, operations: operations) { created.append($0) } } @@ -710,7 +733,7 @@ final class AidenBotInstantEditTests: XCTestCase { func testAFailedCreateOffersTryAgainWithTheSameBotAndNeverMakesTwo() async throws { let mac = CreateMac() - let flow = AidenBotCreateFlow() + let flow = createFlow() var created = 0 // Lost on the way: Try Again replays the same key. @@ -734,7 +757,7 @@ final class AidenBotInstantEditTests: XCTestCase { func testTryAgainAfterALostCreateReplaysTheSameRequestThroughACatalogChange() async throws { let mac = CreateMac() mac.createErrors = [URLError(.networkConnectionLost)] - let flow = AidenBotCreateFlow() + let flow = createFlow() var created = 0 // The Create answer is lost; while the person is away the Mac's catalog moves on. @@ -761,7 +784,7 @@ final class AidenBotInstantEditTests: XCTestCase { func testABotMadeBeforeItsChatFailedIsNotMadeAgain() async throws { let mac = CreateMac() mac.chatErrors = [URLError(.timedOut)] - let flow = AidenBotCreateFlow() + 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 } @@ -771,9 +794,133 @@ final class AidenBotInstantEditTests: XCTestCase { 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) + + // 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, "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) + } + + @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) + } + func testACreateThatNeverReachesTheMacSaysSoAndSendsNothing() async throws { let mac = CreateMac() - let flow = AidenBotCreateFlow() + 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) From c47d018f6fd7b644794801da4cf1dea991d31d9c Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 23:36:25 -0400 Subject: [PATCH 60/83] =?UTF-8?q?Android=20Bots:=20New=20Bot=20closes=20at?= =?UTF-8?q?=20once=20and=20shows=20Creating=E2=80=A6=20on=20Bots=20home=20?= =?UTF-8?q?(R15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As on iOS: Create closes the form, Bots home shows the Bot on its way, and its chat opens once the Mac made it. A failure takes the row away and offers Try Again, which reopens New Bot with the same answers and replays the frozen request and Idempotency-Key even after the catalog moved. A try that never reached the Mac no longer drops the key of one that may have, and a made Bot is no longer replayed by a later Create with the same answers. Co-Authored-By: Claude Opus 5.5 --- .../features/bots/AidenBotEditorScreen.kt | 177 +++++++++++++----- .../features/bots/AidenBotsHomeScreen.kt | 67 ++++++- .../features/bots/AidenBotsViewModel.kt | 23 +++ android/app/src/main/res/values/strings.xml | 1 + .../bots/AidenBotOptimisticSavesTest.kt | 133 +++++++++++++ 5 files changed, 353 insertions(+), 48 deletions(-) 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 b7d946a65..986ff54e2 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 @@ -17,10 +17,16 @@ 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.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 @@ -118,10 +124,118 @@ class AidenBotCreateAttempts(private val makeKey: () -> UUID = UUID::randomUUID) return AidenBotCreateAttempt(draft, request, makeKey()) } - /** Records how [attempt] ended: only an uncertain failure stays replayable. */ + /** + * 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 + } +} + +/** + * 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. + */ +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 attempts: AidenBotCreateAttempts = AidenBotCreateAttempts(), + 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. */ + data class Failure(@StringRes val message: Int, val draft: AidenBotCreateDraft) + + 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 + + /** 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. + */ + fun start(draft: AidenBotCreateDraft, catalog: AidenBotCapabilityCatalog?): AidenBotCreateAttempt? { + if (_pending.value != null) return null + val attempt = attempts.begin(draft, catalog) ?: return null + _failure.value = null + _pending.value = Pending(attempt.request.name) + return attempt + } + + /** Makes [attempt]'s Bot. Run it where it outlives the form; it settles [pending] either way. */ + suspend fun send(attempt: AidenBotCreateAttempt) { + try { + val made = aidenBotHeldRequest(waitForConnection, holdMillis, limitMillis) { create(attempt.request, attempt.key) } + attempts.succeeded(attempt) + cache(made) + _pending.value = null + _created.tryEmit(made) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + attempts.failed(attempt, error) + _pending.value = null + val message = if (aidenBotChatActivityMacUnreachable(error)) R.string.bot_editor_create_unreachable else R.string.bot_editor_create_failed + _failure.value = Failure(message, attempt.draft) + } finally { + // A pairing change cancels it: nothing is on its way any more. + _pending.value = null + } + } + + /** Try again: New Bot opens with the failed answers ([takePrefill]). */ + fun retry() { + prefill = _failure.value?.draft + _failure.value = null + } + + /** OK: the failure is gone, and New Bot opens empty next time. */ + fun dismissFailure() { + prefill = null + _failure.value = null + } + + /** What New Bot opens with, once. */ + fun takePrefill(): AidenBotCreateDraft? = prefill.also { prefill = null } +} + +/** The Bot create flow for the pairing in use: its Bots are made on, and kept for, that pairing only. */ +fun AidenRemoteCoordinator.botCreateFlow(): AidenBotCreateFlow = pairingMemo("bot-create-flow") { + val host = botSaveHost() + AidenBotCreateFlow( + waitForConnection = host.waitForConnection, + create = { request, key -> host.connected().createBot(request, key) }, + cache = host.cache + ) } /** @@ -136,7 +250,7 @@ fun AidenBotEditorScreen( onBotSaved: (String) -> Unit ) { if (botId == null) { - AidenBotCreateScreen(coordinator, onNavigateBack, onBotSaved) + AidenBotCreateScreen(coordinator, onNavigateBack) } else { AidenBotInstructionsEditorScreen(botId, coordinator, onNavigateBack, onBotSaved) } @@ -192,16 +306,16 @@ 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.takePrefill() ?: AidenBotCreateDraft()) } + 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) } @@ -218,47 +332,17 @@ private fun AidenBotCreateScreen( catalogSettled = true } } - // Create never waits on the connection to be tapped: it waits for the Mac, then sends (R15). - val saveHost = remember(client) { coordinator.botSaveHost() } - // 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 && (attempts.replays(draft) || draft.request(catalog) != null), + // 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 = onNavigateBack, onAction = { - val attempt = attempts.begin(draft, catalog) ?: return@AidenBotEditorScaffold - scope.launch { - isSaving = true - error = null - try { - val created = aidenBotHeldRequest(saveHost.waitForConnection) { saveHost.connected().createBot(attempt.request, attempt.key) } - saveHost.cache(created) - onBotSaved(created.id) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - // No answer (offline, too slow) keeps the attempt, so Create replays its key. - 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 { - saveHost.client()?.let { catalog = it.botCapabilityCatalog() } - } catch (c: CancellationException) { - throw c - } catch (_: Exception) { - } - error = when { - draft.modelIsGone(catalog) -> null - aidenBotChatActivityMacUnreachable(e) -> resources.getString(R.string.bot_editor_create_unreachable) - 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( @@ -325,7 +409,6 @@ private fun AidenBotCreateScreen( style = MaterialTheme.typography.bodySmall, color = palette.secondary ) - error?.let { Text(it, color = palette.danger, style = MaterialTheme.typography.bodySmall) } } } 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 3c6a53d93..b9de5b2a2 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 @@ -351,6 +351,12 @@ private fun AidenBotsHomeContent( 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 creating by createFlow.pending.collectAsStateWithLifecycle() + val createFailure by createFlow.failure.collectAsStateWithLifecycle() + fun openChat(bot: AidenBotSummary) { if (serverInfo?.supportsBotDurableSession == true) { onNavigateToBotChat(bot.id) @@ -373,6 +379,11 @@ private fun AidenBotsHomeContent( } } + 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) @@ -401,6 +412,9 @@ private fun AidenBotsHomeContent( } } } + creating?.let { pending -> + item(key = "creating") { AidenBotPendingCreateRow(pending.name, reduceMotion) } + } when (contentState) { AidenBotsHomeContentState.LOADING -> item(key = "loading") { AidenBotHomeSkeleton(reduceMotion) } AidenBotsHomeContentState.ERROR -> item(key = "error") { @@ -412,7 +426,8 @@ private fun AidenBotsHomeContent( 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( @@ -505,6 +520,23 @@ private fun AidenBotsHomeContent( } ) } + createFailure?.let { failure -> + AlertDialog( + onDismissRequest = createFlow::dismissFailure, + title = { Text(stringResource(R.string.bot_create_failed_title)) }, + text = { Text(stringResource(failure.message)) }, + confirmButton = { + TextButton(onClick = { + createFlow.retry() + 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 }, @@ -515,6 +547,39 @@ private fun AidenBotsHomeContent( } } +/** 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 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/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ef9162276..639dd9ddf 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -713,6 +713,7 @@ New Bot Creating… Aiden couldn’t create this Bot. Try again. + Couldn’t Create Bot 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 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 index f2e9d4854..bb8a08c0e 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt @@ -408,4 +408,137 @@ class AidenBotOptimisticSavesTest { 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 + + suspend fun create(request: AidenBotCreateRequest, key: UUID): AidenBotDetail { + requests += request to key + gate?.await() + if (refuses) throw refused() + val created = made.getOrPut(key) { bot.copy(name = request.name, 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. + flow.retry() + assertNull(flow.failure.value) + val reopened = flow.takePrefill() + assertEquals(draft, reopened) + assertNull("the form takes it once", flow.takePrefill()) + 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)!! + 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) + } } From 4de4bf33f6be493ccc9c296cc7afcae5d567bc3b Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 23:39:38 -0400 Subject: [PATCH 61/83] Android Bots: inline error text reads at 4.5:1 in every theme palette.danger is about 3:1 on the page in several light themes. Inline error text and destructive menu labels on the Bot screens now use an error ink derived from the scheme (the error colour moved toward onSurface just far enough to read at 4.5:1 on the page, cards, sheets and the error tint); icons keep the danger colour. The contrast test now checks captions, Try again links and error text on every surface they are drawn on. Co-Authored-By: Claude Opus 5.5 --- .../features/bots/AidenBotActivity.kt | 42 ++++++++++++++++--- .../features/bots/AidenBotAdvancedScreen.kt | 2 +- .../bots/AidenBotChatActivityScreen.kt | 2 +- .../features/bots/AidenBotChatHeader.kt | 2 +- .../features/bots/AidenBotEditorScreen.kt | 4 +- .../bots/AidenBotGeneratedAvatarLifecycle.kt | 2 +- .../features/bots/AidenBotMemoryScreen.kt | 10 ++--- .../features/bots/AidenBotProfileScreen.kt | 6 +-- .../features/bots/AidenBotRoutines.kt | 4 +- .../features/bots/AidenBotSessionScreen.kt | 6 +-- .../features/bots/AidenBotsHomeScreen.kt | 2 +- .../bots/AidenBotChatPresentationTest.kt | 25 ++++++++--- 12 files changed, 76 insertions(+), 31 deletions(-) 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 index 90ed23617..36d4cc059 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotActivity.kt @@ -32,6 +32,7 @@ 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 @@ -208,14 +209,43 @@ fun aidenBotOutgoingBubble(look: AidenBotOutgoingLook): AidenBotPendingBubble = data class AidenBotBubbleColors(val container: Color, val content: Color) /** - * The chat's quiet text on its background: [caption] for "Sending…", "Queued" and the - * connection hint, [link] for Try again. Both are scheme tokens that read at 4.5:1 or better in - * every theme (the accent alone doesn't in some light themes). + * 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) +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 +} -fun aidenBotChatTextColors(scheme: ColorScheme): AidenBotChatTextColors = - AidenBotChatTextColors(caption = scheme.onSurfaceVariant, link = scheme.secondary) +/** [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 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 36dca1899..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 @@ -434,7 +434,7 @@ fun AidenBotAdvancedSheet( } } - saveError?.let { Text(it, color = palette.danger, style = MaterialTheme.typography.bodySmall) } + saveError?.let { Text(it, color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall) } } } } 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 index cae7c84a5..eb689e70e 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt @@ -227,7 +227,7 @@ fun AidenBotChatActivityDialog( AidenBotChatActivityFailure.GENERIC -> R.string.bot_chat_activity_save_failed } ), - color = palette.danger, + color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall, modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite } ) 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 986ff54e2..bc2498440 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 @@ -397,7 +397,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) ) } @@ -489,7 +489,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/AidenBotMemoryScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotMemoryScreen.kt index e07723e40..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 @@ -130,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 ) } @@ -182,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) @@ -190,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) ) @@ -330,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/AidenBotProfileScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotProfileScreen.kt index 9a9bd695a..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 @@ -294,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 @@ -403,7 +403,7 @@ fun AidenBotProfileScreen( } } } - 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 { @@ -491,7 +491,7 @@ fun AidenBotProfileScreen( } (editState?.failure?.let { stringResource(aidenBotSaveFailureText(it)) } ?: actionError)?.let { - Text(it, color = palette.danger, style = MaterialTheme.typography.bodySmall) + Text(it, color = aidenBotErrorTextColor(), style = MaterialTheme.typography.bodySmall) } } } 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/AidenBotSessionScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt index d246f11dc..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 @@ -473,7 +473,7 @@ private fun AidenBotSessionContent( // 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) } } } @@ -668,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()) } } } @@ -781,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()) } } } 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 b9de5b2a2..3f929791c 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 @@ -738,7 +738,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/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatPresentationTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatPresentationTest.kt index 79524c8a5..5e0b0aca8 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatPresentationTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatPresentationTest.kt @@ -48,18 +48,33 @@ class AidenBotChatPresentationTest { } @Test - fun captionsAndTryAgainReadOnTheChatInEveryTheme() { + 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"}" - val caption = AidenPalette.contrastRatio(text.caption, scheme.background) - val link = AidenPalette.contrastRatio(text.link, scheme.background) - assertTrue("$name: caption contrast $caption is below 4.5:1", caption >= 4.5f) - assertTrue("$name: Try again contrast $link is below 4.5:1", link >= 4.5f) + // 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 From 212f6925472df0702a4cf9a9cd0f5d301e61e09e Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 23:43:27 -0400 Subject: [PATCH 62/83] Android Bots test: Bots home shows a Bot on its way and Try Again reopens New Bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instrumented (headless AVD): the Creating… row shows from the start of a create, a failure removes it and says so, and Try Again reopens New Bot with the same answers. Co-Authored-By: Claude Opus 5.5 --- .../bots/AidenBotCreatePendingUiTest.kt | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 android/app/src/androidTest/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCreatePendingUiTest.kt 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..bca588b57 --- /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.takePrefill()) + } + compose.onNodeWithText("Couldn’t Create Bot").assertDoesNotExist() + } +} From 7eddc5be89756c548f4f43d7da3c5ba70c0a18cf Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 23:47:29 -0400 Subject: [PATCH 63/83] iOS Bots: the Files sheet loads with skeleton rows, not a spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bot chat's Files sheet was the last Bot surface still showing ProgressView("Loading files…") on a cold load (R15). The Files section now shows file-shaped skeleton rows built from AidenBotSkeletonBlock (still under Reduce Motion and Low Power), read by VoiceOver as one "Loading files". Co-Authored-By: Claude Opus 5.5 --- .../Remote/AidenBotChatToolsView.swift | 37 ++++++++++++++++--- .../AidenBotHomeProfileTests.swift | 32 ++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) 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/AidenOnTheGoTests/AidenBotHomeProfileTests.swift b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift index b111c02be..0543da2f5 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( From e348b0131ee7255eddc2fc6c8ecd9da56d8dead1 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 14 Oct 2026 23:53:12 -0400 Subject: [PATCH 64/83] Android Bots: a New Bot cut off by a process death comes back with Try Again As iOS 9685204a9: the create flow lived only in memory, so a process death mid-create lost the answers and the Idempotency-Key. The ask (answers, the frozen request body and its key) is now on disk per pairing before anything goes out, beside the pairing's chat outboxes in noBackupFilesDir (AtomicFile), so an unpair purges it and nothing is written for that pairing after; a 401/403 on the create drops it. On relaunch Bots home shows it as not made: within a day Try Again sends the same request on the same key, so the Mac replays its one Bot; after that, or after a refusal, it reopens New Bot as it was. A Bot the Mac's list already shows clears it quietly; OK, or closing the reopened New Bot, lets it go. Co-Authored-By: Claude Opus 5.5 --- .../bots/AidenBotCreatePendingUiTest.kt | 2 +- .../features/bots/AidenBotEditorScreen.kt | 193 ++++++++++++++++-- .../features/bots/AidenBotOutbox.kt | 139 +++++++++++++ .../features/bots/AidenBotsHomeScreen.kt | 14 +- android/app/src/main/res/values/strings.xml | 1 + .../bots/AidenBotOptimisticSavesTest.kt | 141 ++++++++++++- 6 files changed, 463 insertions(+), 27 deletions(-) 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 index bca588b57..ee2a0207b 100644 --- a/android/app/src/androidTest/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCreatePendingUiTest.kt +++ b/android/app/src/androidTest/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotCreatePendingUiTest.kt @@ -78,7 +78,7 @@ class AidenBotCreatePendingUiTest { compose.runOnIdle { assertEquals(1, reopened) - assertEquals("New Bot opens with the same answers", draft, flow.takePrefill()) + 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/features/bots/AidenBotEditorScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotEditorScreen.kt index bc2498440..3b33a819e 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 @@ -96,7 +97,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 ) /** @@ -107,9 +110,15 @@ class AidenBotCreateAttempt internal constructor( * 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`. */ -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 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 and [draft] is unchanged, so Create replays it. */ fun replays(draft: AidenBotCreateDraft): Boolean = uncertain?.draft == draft @@ -121,7 +130,17 @@ class AidenBotCreateAttempts(private val makeKey: () -> UUID = UUID::randomUUID) uncertain?.takeIf { it.draft == 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 + } + + /** Nothing to replay any more: access ended, or the Mac's list shows the Bot. */ + fun forget() { + uncertain = null } /** @@ -140,6 +159,13 @@ class AidenBotCreateAttempts(private val makeKey: () -> UUID = UUID::randomUUID) } } +/** Where one pairing's New Bot on its way is kept across launches (beside its chats' outboxes). */ +class AidenBotCreateStore( + val load: suspend () -> AidenBotPendingCreate?, + val save: (AidenBotPendingCreate) -> Unit, + 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 @@ -147,21 +173,39 @@ class AidenBotCreateAttempts(private val makeKey: () -> UUID = UUID::randomUUID) * [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 is in [store] before anything goes out and 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); + * after that, or when nothing could have reached the Mac, it reopens New Bot as it was. */ 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 attempts: AidenBotCreateAttempts = AidenBotCreateAttempts(), + private val store: AidenBotCreateStore? = null, + 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. */ - data class Failure(@StringRes val message: Int, val draft: AidenBotCreateDraft) + /** + * 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() @@ -174,6 +218,7 @@ class AidenBotCreateFlow( val created: SharedFlow = _created.asSharedFlow() private var prefill: AidenBotCreateDraft? = null + private var restored = false /** True when [draft] would replay a try that may have reached the Mac. */ fun replays(draft: AidenBotCreateDraft): Boolean = attempts.replays(draft) @@ -181,11 +226,13 @@ class AidenBotCreateFlow( /** * 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. + * shapes a new request, never a replayed one. The ask is kept before anything goes out. */ fun start(draft: AidenBotCreateDraft, catalog: AidenBotCapabilityCatalog?): AidenBotCreateAttempt? { if (_pending.value != null) return null val attempt = attempts.begin(draft, catalog) ?: return null + store?.save(AidenBotPendingCreate(draft, attempt.request, attempt.key, attempt.createdAtMs)) + prefill = null _failure.value = null _pending.value = Pending(attempt.request.name) return attempt @@ -196,45 +243,147 @@ class AidenBotCreateFlow( try { 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) { - attempts.failed(attempt, error) _pending.value = null val message = if (aidenBotChatActivityMacUnreachable(error)) R.string.bot_editor_create_unreachable else R.string.bot_editor_create_failed - _failure.value = Failure(message, attempt.draft) + 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.replays(attempt.draft) + // 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 } } - /** Try again: New Bot opens with the failed answers ([takePrefill]). */ - fun retry() { - prefill = _failure.value?.draft + /** + * 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. + */ + suspend fun restore() { + val store = store ?: return + if (restored || !isFree()) return + restored = true + val saved = try { + store.load() + } catch (error: CancellationException) { + restored = false + throw error + } catch (_: Exception) { + null + } ?: 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 && nowMs() - saved.createdAtMs < AidenBotSessionStore.LIFETIME_MS + if (replays) attempts.adopt(AidenBotCreateAttempt(saved.draft, request!!, key!!, saved.createdAtMs)) + _failure.value = Failure( + 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: the attempt to [send] comes back. Otherwise New Bot opens with the answers + * ([reopening]) and this returns null. + */ + fun retry(): AidenBotCreateAttempt? { + val failed = _failure.value ?: return null + if (failed.replays) return start(failed.draft, null) + prefill = failed.draft _failure.value = null + return null } - /** OK: the failure is gone, and New Bot opens empty next time. */ + /** 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() } - /** What New Bot opens with, once. */ - fun takePrefill(): AidenBotCreateDraft? = prefill.also { prefill = null } + /** 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. */ +/** + * 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 store = installationStore.activeInstallation?.let { installation -> + val instanceId = installation.instanceId + val deviceId = installation.deviceId + AidenBotCreateStore( + load = { botSessionStore.pendingCreate(instanceId, deviceId) }, + save = { botSessionStore.savePendingCreate(instanceId, deviceId, it) }, + remove = { botSessionStore.removePendingCreate(instanceId, deviceId) } + ) + } AidenBotCreateFlow( waitForConnection = host.waitForConnection, create = { request, key -> host.connected().createBot(request, key) }, - cache = host.cache + cache = host.cache, + store = store ) } @@ -314,7 +463,13 @@ private fun AidenBotCreateScreen( // 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.takePrefill() ?: AidenBotCreateDraft()) } + 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. @@ -338,7 +493,7 @@ private fun AidenBotCreateScreen( // 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 = onNavigateBack, + onNavigateBack = close, onAction = { val attempt = flow.start(draft, catalog) ?: return@AidenBotEditorScaffold saveHost.launch { flow.send(attempt) } 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 index 141d7f349..6d4d0dfc5 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt @@ -6,7 +6,9 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.encodeToJsonElement +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 @@ -114,6 +116,18 @@ data class AidenBotOutgoingMessage( /** 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 and its + * idempotency [key] (null when nothing that could reach the Mac was sent), and when it began. + */ +data class AidenBotPendingCreate( + val draft: AidenBotCreateDraft, + val request: AidenBotCreateRequest? = null, + val key: UUID? = null, + val createdAtMs: Long +) + /** How the chat draws an outgoing message (R14). */ sealed class AidenBotOutgoingLook { /** Faded, "Sending…". */ @@ -152,6 +166,10 @@ class AidenBotSessionStore( private val owners = HashMap() /** Chats whose unreadable file couldn't be moved aside: it is never written over. */ private val stuck = HashSet() + /** Pairings (instance, device) 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. */ + private val revokedCreates = HashSet>() @Synchronized fun transcript(key: AidenBotSessionKey): List? = transcripts[key] @@ -255,10 +273,129 @@ class AidenBotSessionStore( outboxes.keys.removeAll { it.instanceId == instanceId } owners.keys.removeAll { it.instanceId == instanceId } stuck.removeAll { it.instanceId == instanceId } + revokedCreates += createPairings.filter { it.first == 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. Its age is the caller's to judge (see [AidenBotCreateFlow]). + */ + suspend fun pendingCreate(instanceId: String, deviceId: String): AidenBotPendingCreate? = + suspendCancellableCoroutine { continuation -> + synchronized(this) { createPairings += instanceId to deviceId } + val task = Runnable { continuation.resumeWith(runCatching { loadCreate(instanceId, deviceId) }) } + try { + io.execute(task) + } catch (error: RejectedExecutionException) { + continuation.resumeWith(Result.failure(error)) + } + } + + /** Keeps the pairing's New Bot on its way, before anything of it goes out; never for a pairing an unpair purged. */ + fun savePendingCreate(instanceId: String, deviceId: String, create: AidenBotPendingCreate) { + val pairing = instanceId to deviceId + synchronized(this) { + createPairings += pairing + if (pairing in revokedCreates) return + } + val record = CreateRecord.of(instanceId, deviceId, create) + io.execute { writeCreate(instanceId, deviceId, record) } + } + + /** The New Bot was made, let go of, or this phone lost access: nothing of it is kept. */ + fun removePendingCreate(instanceId: String, deviceId: String) { + io.execute { createFileFor(instanceId, deviceId)?.let { AtomicFile(it).delete() } } + } + + @Serializable + private data class CreateRecord( + val version: Int, + val instanceId: String, + val deviceId: String, + 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) + require((request == null) == (idempotencyKey == null)) + return AidenBotPendingCreate(AidenBotCreateDraft(name, help, route), request, idempotencyKey, createdAt) + } + + companion object { + fun of(instanceId: String, deviceId: String, create: AidenBotPendingCreate) = CreateRecord( + version = 1, + instanceId = instanceId, + deviceId = deviceId, + 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(instanceId: String, deviceId: String): AidenBotPendingCreate? { + val file = createFileFor(instanceId, deviceId) ?: return null + if (!file.exists() && !File(file.path + ".bak").exists()) return null + val create = 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 == instanceId && it.deviceId == deviceId } + ?.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 create + } + + private fun writeCreate(instanceId: String, deviceId: String, record: CreateRecord) { + val file = createFileFor(instanceId, deviceId) ?: return + if (synchronized(this) { (instanceId to deviceId) in revokedCreates }) return + val atomic = AtomicFile(file) + var stream: java.io.FileOutputStream? = null + try { + val data = json.encodeToString(CreateRecord.serializer(), record).toByteArray(Charsets.UTF_8) + file.parentFile?.mkdirs() + stream = atomic.startWrite() + stream.write(data) + atomic.finishWrite(stream) + } catch (_: Exception) { + stream?.let { atomic.failWrite(it) } + report("event=bot_create_write outcome=failed") + } + } + + /** Beside the pairing's chat outboxes (named by 64-character digests, so never this name), and purged with them. */ + internal fun createFileFor(instanceId: String, deviceId: String): File? = root?.let { + File(File(File(it, digest(instanceId)), digest(deviceId)), "create.json") + } + // --- Disk --- @Serializable @@ -449,6 +586,8 @@ class AidenBotSessionStore( 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 } } 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 3f929791c..8d6ca4b11 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 @@ -354,8 +354,11 @@ private fun AidenBotsHomeContent( // 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) { @@ -379,6 +382,12 @@ private fun AidenBotsHomeContent( } } + // 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()) } @@ -527,8 +536,9 @@ private fun AidenBotsHomeContent( text = { Text(stringResource(failure.message)) }, confirmButton = { TextButton(onClick = { - createFlow.retry() - onNavigateToCreateBot() + // An earlier launch's create goes out again as it was; otherwise New Bot reopens. + val again = createFlow.retry() + if (again != null) createHost.launch { createFlow.send(again) } else onNavigateToCreateBot() }) { Text(stringResource(R.string.action_try_again), color = palette.accent) } }, dismissButton = { diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 639dd9ddf..ca05a9797 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -714,6 +714,7 @@ 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 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 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 index bb8a08c0e..b5e4d279e 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt @@ -11,6 +11,7 @@ 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 @@ -318,7 +319,7 @@ class AidenBotOptimisticSavesTest { @Test fun aCreateThatTimedOutReplaysItsKey() { val keys = ArrayDeque(List(2) { UUID.randomUUID() }) - val attempts = AidenBotCreateAttempts { keys.removeFirst() } + val attempts = AidenBotCreateAttempts(makeKey = { keys.removeFirst() }) val draft = AidenBotCreateDraft(name = "Chef", help = "Plans meals") val first = attempts.begin(draft, catalog)!! attempts.failed(first, AidenBotChatActivityTimeout()) @@ -419,12 +420,14 @@ class AidenBotOptimisticSavesTest { /** 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() - val created = made.getOrPut(key) { bot.copy(name = request.name, revision = "bot_made_${made.size + 1}") } + 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() @@ -481,14 +484,14 @@ class AidenBotOptimisticSavesTest { 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. - flow.retry() + assertNull("this launch's failure reopens New Bot", flow.retry()) assertNull(flow.failure.value) - val reopened = flow.takePrefill() + val reopened = flow.reopening() assertEquals(draft, reopened) - assertNull("the form takes it once", flow.takePrefill()) 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) @@ -541,4 +544,132 @@ class AidenBotOptimisticSavesTest { 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) + + /** One launch of the app: a flow whose ask waits in a session store over [root]. */ + private fun TestScope.launchWith(root: java.io.File, mac: CreatingMac, clock: () -> Long = { currentTime }): AidenBotCreateFlow { + val sessions = AidenBotSessionStore(root, io = { it.run() }, nowMs = clock) + return AidenBotCreateFlow( + waitForConnection = { true }, + create = mac::create, + cache = {}, + store = AidenBotCreateStore( + load = { sessions.pendingCreate(pairing.instanceId, pairing.deviceId) }, + save = { sessions.savePendingCreate(pairing.instanceId, pairing.deviceId, it) }, + remove = { sessions.removePendingCreate(pairing.instanceId, pairing.deviceId) } + ), + 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) + } + + @Test + fun aCutOffCreateWhoseKeyTheMacNoLongerKeepsReopensNewBotInstead() = 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.LIFETIME_MS + 1 + val after = launchWith(root, mac) { now } + after.restore() + assertFalse("a day on, the same key could make a second Bot", after.failure.value!!.replays) + assertNull(after.retry()) + assertEquals("New Bot opens as it was", draft, after.reopening()) + assertFalse(after.start(draft, catalog)!!.key == first.key) + } + + @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() + phone.botCreateFlow().start(AidenBotCreateDraft(name = "Chef"), catalog)!! + assertEquals("Chef", phone.botSessionStore.pendingCreate(pairing.instanceId, pairing.deviceId)?.draft?.name) + assertNull("another pairing has none", phone.botSessionStore.pendingCreate(pairing.instanceId, "device-other")) + phone.removeInstallation(phone.installationStore.installations.value.single().id) + assertNull(phone.botSessionStore.pendingCreate(pairing.instanceId, pairing.deviceId)) + phone.botSessionStore.savePendingCreate(pairing.instanceId, pairing.deviceId, AidenBotPendingCreate(AidenBotCreateDraft(name = "Late"), createdAtMs = 0)) + assertNull("never written again after the unpair", phone.botSessionStore.pendingCreate(pairing.instanceId, pairing.deviceId)) + } } From d4d63580da573f4ca43690d3bb12b0b166fc4528 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 00:00:01 -0400 Subject: [PATCH 65/83] iOS Bots test: every Chat activity hold is bounded by a deadline The scripted saves in AidenBotChatActivityTests waited on bare continuations, and one on an unbounded while-sleep loop, so a test that failed before letting its save go left the pick hanging until the execution allowance. They now wait on a Hold that fails the test after 5 seconds (checked by leaving one held: it fails in 5.05 s instead of hanging); the polls for a save to start were already bounded by untilSaving. Co-Authored-By: Claude Opus 5.5 --- .../AidenBotSessionTests.swift | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index a0e8e6b90..07c79c7bb 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -3679,6 +3679,28 @@ final class AidenBotChatActivityTests: XCTestCase { } } + /// 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", @@ -3721,13 +3743,11 @@ final class AidenBotChatActivityTests: XCTestCase { func testASaveThatReturnsAfterThePairingChangedIsNeitherKeptNorReported() async throws { var paired = true - var holding = false - var letGo = false + let hold = Hold() var saved: [AidenBotDetail] = [] var refreshes = 0 let model = try model(save: { next, _ in - holding = true - while !letGo { try await Task.sleep(for: .milliseconds(5)) } + try await hold.wait() return try self.detail(revision: "rev_2", next) }, refresh: { refreshes += 1 @@ -3735,9 +3755,9 @@ final class AidenBotChatActivityTests: XCTestCase { }, onSaved: { saved.append($0) }, isCurrent: { paired }) let pick = Task { await model.pick(AidenBotChatActivity(level: .detailed, wording: .follows)) } - try await untilSaving { holding } + try await untilSaving { hold.isHeld } paired = false - letGo = true + hold.release() await pick.value XCTAssertEqual(saved.count, 0, "the old Mac's Bot is never cached or reported under the new pairing") @@ -3817,15 +3837,15 @@ final class AidenBotChatActivityTests: XCTestCase { func testAPickShowsAtOnceAndASecondTapWhileSavingIsNeitherLostNorSentOnAStaleRevision() async throws { var calls: [(AidenBotChatActivity, String)] = [] - var release: CheckedContinuation? + let hold = Hold() let model = try model(save: { next, revision in calls.append((next, revision)) - if calls.count == 1 { await withCheckedContinuation { release = $0 } } + 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 { release != nil } + try await untilSaving { hold.isHeld } XCTAssertEqual(model.value.level, .summary, "the choice shows before the Mac answers") XCTAssertTrue(model.isSaving) @@ -3833,7 +3853,7 @@ final class AidenBotChatActivityTests: XCTestCase { 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") - release?.resume() + 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) @@ -3843,18 +3863,18 @@ final class AidenBotChatActivityTests: XCTestCase { func testThreeTapsDuringOneSaveSendTheFirstAndTheLastOnly() async throws { var calls: [AidenBotChatActivity] = [] - var release: CheckedContinuation? + let hold = Hold() let model = try model(save: { next, _ in calls.append(next) - if calls.count == 1 { await withCheckedContinuation { release = $0 } } + 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 { release != nil } + 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)) - release?.resume() + hold.release() await first.value XCTAssertEqual(calls, [ AidenBotChatActivity(level: .summary, wording: .follows), @@ -3865,18 +3885,18 @@ final class AidenBotChatActivityTests: XCTestCase { func testPickingBackToTheSavedChoiceMidFlightEndsOnThatChoiceOnTheMacToo() async throws { var calls: [AidenBotChatActivity] = [] - var release: CheckedContinuation? + 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 { await withCheckedContinuation { release = $0 } } + 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 { release != nil } + try await untilSaving { hold.isHeld } await model.pick(original) XCTAssertEqual(model.value, original, "the page already shows the choice they went back to") - release?.resume() + hold.release() await first.value XCTAssertEqual(calls.last, original, "the Mac ends on it too, not on the abandoned pick") XCTAssertEqual(model.value, original) From 978caa670424bb9070f09a66eb870d54758305ae Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 00:01:41 -0400 Subject: [PATCH 66/83] iOS Bots: a Chat activity pick made while a save timed out is not dropped A timeout (or a conflict) re-reads the Bot, and a save that landed counts as saved. The pick made while that save was in flight was cleared before the re-read, so when the refresh showed the first pick had landed the second one was silently lost: the page showed the first choice with no error. It is now cleared only when the save rolls back, so it goes out next on the revision the refresh read. A pairing that changed during the refresh gives up quietly. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotAdvancedView.swift | 6 ++++- .../AidenBotSessionTests.swift | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift index 100a3ba1c..bcdbbe504 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift @@ -1095,8 +1095,8 @@ final class AidenBotChatActivityModel { return true } catch { guard isCurrent() else { return giveUp() } - pending = nil if error is CancellationError { + pending = nil value = saved return false } @@ -1104,8 +1104,12 @@ final class AidenBotChatActivityModel { 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 diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index 07c79c7bb..a24e57384 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -4049,6 +4049,28 @@ final class AidenBotChatActivityTests: XCTestCase { 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)) From 4030c06a55e4be918c7d6287251753ccf2252829 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 00:03:27 -0400 Subject: [PATCH 67/83] iOS Bots: a Chat activity pick still reports when the sheet closes from its page AidenBotChatActivityReport held a confirmed pick while Advanced was open and reported it when Advanced's onDisappear ran. Advanced had already disappeared when Chat activity was pushed over it, so closing the Profile sheet (or popping back to the chat) straight from the Chat activity page never closed the report: the held pick, and every later one, never reached the screens behind (the Bot cache still got them). The report now tracks both pages. Pushing Chat activity hands the hold over, the last page to leave reports what is held once, and later saves go straight behind. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotAdvancedView.swift | 49 ++++++++++++------- .../AidenBotSessionTests.swift | 37 +++++++++++++- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift index bcdbbe504..7e8263d49 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotAdvancedView.swift @@ -157,11 +157,15 @@ struct AidenBotAdvancedView: View { let expectedSession = sessionIdentity Task { await load(for: expectedSession) } } - .onAppear { chatActivityReport.advancedOpened() } + .onAppear { chatActivityReport.appeared(.advanced) } .onDisappear { - // Closing Advanced (not opening Chat activity) reports a saved pick once; + // Closing Advanced (not opening Chat activity over it) reports a saved pick once; // one that lands later is reported when it lands. - if !isShowingChatActivity { chatActivityReport.advancedClosed(reportBehind: onSaved) } + chatActivityReport.disappeared( + .advanced, + replacedBy: isShowingChatActivity ? .chatActivity : nil, + reportBehind: onSaved + ) } .confirmationDialog( "Discard changes?", @@ -245,6 +249,9 @@ struct AidenBotAdvancedView: View { 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) } } } } @@ -966,31 +973,37 @@ func aidenBotChatActivityTransportFailure(_ error: Error) -> Bool { error is AidenBotRequestTimedOut || error is URLError } -/// Reports Chat activity saves to the screens behind Advanced. While Advanced is -/// open it holds the newest, to report once when Advanced closes; a save that -/// lands after Advanced closed is reported at once, so it is never lost. +/// 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 { - private var advancedOpen = true + enum Page: Hashable { + case advanced, chatActivity + } + + private var showing: Set = [.advanced] private var unreported: AidenBotDetail? func saved(_ detail: AidenBotDetail, reportBehind: (AidenBotDetail) -> Void) { - if advancedOpen { - unreported = detail - } else { + if showing.isEmpty { reportBehind(detail) + } else { + unreported = detail } } - func advancedOpened() { advancedOpen = true } + func appeared(_ page: Page) { showing.insert(page) } - /// Advanced closed: what it held is reported once, and later saves go straight behind it. - func advancedClosed(reportBehind: (AidenBotDetail) -> Void) { - advancedOpen = false - if let detail = unreported { - unreported = nil - reportBehind(detail) - } + /// `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. diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index a24e57384..c6033a082 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -4098,7 +4098,7 @@ final class AidenBotChatActivityTests: XCTestCase { 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.advancedClosed(reportBehind: { behind.append($0.revision) }) + 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") @@ -4107,10 +4107,43 @@ final class AidenBotChatActivityTests: XCTestCase { let own = AidenBotChatActivityReport() own.saved(first, reportBehind: { behind.append($0.revision) }) own.reportedByAdvanced() - own.advancedClosed(reportBehind: { behind.append($0.revision) }) + 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"]) From 89b91a071eb4a3ec881e6ca7860b6012958f0eb8 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 00:05:46 -0400 Subject: [PATCH 68/83] iOS Bots: a restored New Bot that fails again still replays on Try Again A create an earlier launch left behind replays its frozen request when Try Again is chosen. If that replay failed again (the Mac unreachable, or an answer lost), the next failure reopened New Bot instead, which rebuilds the ask from the current catalog and could miss the frozen key. A restored attempt now keeps replaying until it is made or let go. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotCreateView.swift | 13 +++++++++++-- .../AidenBotHomeProfileTests.swift | 5 +++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift index 385337ca0..437b0a336 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift @@ -92,6 +92,8 @@ final class AidenBotCreateFlow { 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 } @ObservationIgnored private var retained: Attempt? @@ -236,7 +238,7 @@ final class AidenBotCreateFlow { guard let saved = await store.pendingCreate(instanceId: instanceId, deviceId: deviceId), isFree(for: scope) else { return } if let frozen = saved.frozen { - retained = Attempt(scope: scope, input: saved.input, frozen: frozen, createdAt: saved.createdAt) + retained = Attempt(scope: scope, input: saved.input, frozen: frozen, createdAt: saved.createdAt, restored: true) } failure = Failure( message: Self.interrupted, @@ -261,8 +263,15 @@ final class AidenBotCreateFlow { pending == nil && failure == nil && retained?.scope != scope && !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 { - Failure(message: message, input: input, instanceId: operations.instanceId, deviceId: operations.deviceId) + Failure( + message: message, + input: input, + replays: retained.map { $0.scope == operations.scope && $0.restored } ?? false, + instanceId: operations.instanceId, + deviceId: operations.deviceId + ) } private func record(_ attempt: Attempt) -> AidenBotPendingCreate { diff --git a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift index 0543da2f5..a8241daac 100644 --- a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift @@ -857,6 +857,11 @@ final class AidenBotInstantEditTests: XCTestCase { 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] = [] From 9c35409052499c4253776ebc75c80ef9d827569d Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 00:07:51 -0400 Subject: [PATCH 69/83] Docs: record the pre-merge bug sweep for Bot chat activity D Co-Authored-By: Claude Opus 5.5 --- .memory/bot-chat-activity.md | 3 ++- .../specs/2026-10-14-bot-chat-activity-design.md | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.memory/bot-chat-activity.md b/.memory/bot-chat-activity.md index e5d25642a..d3eab52f9 100644 --- a/.memory/bot-chat-activity.md +++ b/.memory/bot-chat-activity.md @@ -90,6 +90,7 @@ If `main` has moved past 29, change every `30` D introduced to the next free rev - 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) -- Android items the final wave did not close (see the TODO above); the iOS Files sheet `ProgressView`. +- 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/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md b/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md index b3bbe512d..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 @@ -478,11 +478,12 @@ Every new test file is registered in the matching `package.json` script and `scr - 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). - - Open on iOS: the Files sheet in `AidenBotChatToolsView` still shows a `ProgressView`; no manual VoiceOver pass or live-Mac run. + - 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).** They are follow-ups unless the Android report closes them: + - **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: the Android raw-null guard for the null-literal samples; a whitespace-only `app` label parity note. + - 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. From 0f43fe002856802bbc775259d6463c679c6e7cca Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 00:35:42 -0400 Subject: [PATCH 70/83] Android Bots: a New Bot goes out only once its request and key are on disk Create and a restored Try Again queued the pending-create write and sent at once, so a slow disk queue plus a process death after the Mac committed lost the recovery identity. The store's save now answers once the AtomicFile write has synced and landed on its io queue (checking the sync and rename that finishWrite only logs), and send waits for that answer. A write that fails sends nothing and offers Try again with the same answers. Co-Authored-By: Claude Opus 5.5 --- .../features/bots/AidenBotEditorScreen.kt | 49 +++++++++-- .../features/bots/AidenBotOutbox.kt | 47 ++++++++-- android/app/src/main/res/values/strings.xml | 1 + .../bots/AidenBotOptimisticSavesTest.kt | 86 ++++++++++++++++++- 4 files changed, 164 insertions(+), 19 deletions(-) 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 3b33a819e..96149aee8 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 @@ -22,6 +22,7 @@ import androidx.annotation.StringRes import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -162,7 +163,8 @@ class AidenBotCreateAttempts( /** Where one pairing's New Bot on its way is kept across launches (beside its chats' outboxes). */ class AidenBotCreateStore( val load: suspend () -> AidenBotPendingCreate?, - val save: (AidenBotPendingCreate) -> Unit, + /** 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 ) @@ -174,10 +176,11 @@ class AidenBotCreateStore( * 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 is in [store] before anything goes out and 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); - * after that, or when nothing could have reached the Mac, it reopens New Bot as it was. + * 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); after + * that, or when nothing could have reached the Mac, it reopens New Bot as it was. */ class AidenBotCreateFlow( private val waitForConnection: (suspend () -> Boolean)?, @@ -219,6 +222,8 @@ class AidenBotCreateFlow( 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) @@ -226,21 +231,49 @@ class AidenBotCreateFlow( /** * 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 is kept before anything goes out. + * 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) return null val attempt = attempts.begin(draft, catalog) ?: return null - store?.save(AidenBotPendingCreate(draft, attempt.request, attempt.key, attempt.createdAtMs)) + keeping = store?.let { attempt to it.save(attempt.kept()) } prefill = null _failure.value = null _pending.value = Pending(attempt.request.name) return attempt } - /** Makes [attempt]'s Bot. Run it where it outlives the form; it settles [pending] either way. */ + 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.replays(attempt.draft) + _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() 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 index 6d4d0dfc5..5da1e7910 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt @@ -1,6 +1,8 @@ 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 @@ -296,15 +298,34 @@ class AidenBotSessionStore( } } - /** Keeps the pairing's New Bot on its way, before anything of it goes out; never for a pairing an unpair purged. */ - fun savePendingCreate(instanceId: String, deviceId: String, create: AidenBotPendingCreate) { + /** + * 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(instanceId: String, deviceId: String, create: AidenBotPendingCreate): Deferred { + val kept = CompletableDeferred() val pairing = instanceId to deviceId synchronized(this) { createPairings += pairing - if (pairing in revokedCreates) return + if (pairing in revokedCreates) return kept.apply { complete(false) } } val record = CreateRecord.of(instanceId, deviceId, create) - io.execute { writeCreate(instanceId, deviceId, record) } + try { + io.execute { + var written = false + try { + written = writeCreate(instanceId, deviceId, 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. */ @@ -374,20 +395,30 @@ class AidenBotSessionStore( return create } - private fun writeCreate(instanceId: String, deviceId: String, record: CreateRecord) { - val file = createFileFor(instanceId, deviceId) ?: return - if (synchronized(this) { (instanceId to deviceId) in revokedCreates }) return + /** + * 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(instanceId: String, deviceId: String, record: CreateRecord): Boolean { + val file = createFileFor(instanceId, deviceId) ?: return true + if (synchronized(this) { (instanceId to deviceId) in revokedCreates }) return false val atomic = AtomicFile(file) var stream: java.io.FileOutputStream? = null - try { + 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 } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ca05a9797..240828fd3 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -715,6 +715,7 @@ 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 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 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 index b5e4d279e..11949554d 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt @@ -549,9 +549,14 @@ class AidenBotOptimisticSavesTest { private fun body(request: AidenBotCreateRequest) = json.encodeToString(AidenBotCreateRequest.serializer(), request) - /** One launch of the app: a flow whose ask waits in a session store over [root]. */ - private fun TestScope.launchWith(root: java.io.File, mac: CreatingMac, clock: () -> Long = { currentTime }): AidenBotCreateFlow { - val sessions = AidenBotSessionStore(root, io = { it.run() }, nowMs = clock) + /** 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, @@ -599,6 +604,81 @@ class AidenBotOptimisticSavesTest { 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 onDisk(root: java.io.File): AidenBotPendingCreate? = + AidenBotSessionStore(root, io = { it.run() }).pendingCreate(pairing.instanceId, pairing.deviceId) + + @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 aCutOffCreateWhoseKeyTheMacNoLongerKeepsReopensNewBotInstead() = runTest { val root = tempFolder.newFolder("outbox") From 6b7b8c01a4ad3153af39790c06f9043479eb87c3 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 00:39:52 -0400 Subject: [PATCH 71/83] Android Bots: a restored New Bot stays with the pairing it was made on A pending create now belongs to one pairing: the Mac, this phone's device id there and when they were paired. The store records the pairing's time, and a record an earlier pairing of the same Mac left behind is dropped rather than restored. The create flow starts, restores and retries nothing while another pairing is in use: a restore that lands after a switch shows nothing and is read again on the pairing's next visit, so Try Again never sends one Mac's answers to another. The Bot save and Chat activity hosts take only the client made for their pairing, so the moment between a switch and the new client can't hand one pairing's work the other's client. Co-Authored-By: Claude Opus 5.5 --- .../bots/AidenBotChatActivityScreen.kt | 5 +- .../features/bots/AidenBotEditorScreen.kt | 37 ++++-- .../features/bots/AidenBotOptimisticSaves.kt | 5 +- .../features/bots/AidenBotOutbox.kt | 76 ++++++----- .../features/bots/AidenBotsHomeScreen.kt | 8 +- .../features/remote/AidenRemoteCoordinator.kt | 24 +++- .../bots/AidenBotOptimisticSavesTest.kt | 118 ++++++++++++++++-- 7 files changed, 216 insertions(+), 57 deletions(-) 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 index eb689e70e..454d50775 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotChatActivityScreen.kt @@ -41,6 +41,7 @@ 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 @@ -146,8 +147,8 @@ fun AidenRemoteCoordinator.botChatActivityHost(): AidenBotChatActivityHost = pai val pairing = activePairing fun samePairing() = holdsPairing(pairing) AidenBotChatActivityHost( - client = { if (samePairing()) client.value else null }, - waitForConnection = { samePairing() && aidenBotAwaitConnected(connectionState, client) && samePairing() }, + client = { pairedClient(pairing) }, + waitForConnection = { samePairing() && aidenBotAwaitConnected(connectionState, client.map { pairedClient(pairing) }) && samePairing() }, launch = { block -> launchDetached(block) }, cache = pinnedBotCache() ) 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 96149aee8..011b63e42 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 @@ -181,6 +181,9 @@ class AidenBotCreateStore( * 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); after * that, or when nothing could have reached the Mac, it reopens New Bot as it was. + * + * 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)?, @@ -188,6 +191,8 @@ class AidenBotCreateFlow( /** 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, @@ -235,7 +240,7 @@ class AidenBotCreateFlow( * even if [send] never runs; [send] waits for it. */ fun start(draft: AidenBotCreateDraft, catalog: AidenBotCapabilityCatalog?): AidenBotCreateAttempt? { - if (_pending.value != null) return null + if (_pending.value != null || !holdsPairing()) return null val attempt = attempts.begin(draft, catalog) ?: return null keeping = store?.let { attempt to it.save(attempt.kept()) } prefill = null @@ -313,11 +318,13 @@ class AidenBotCreateFlow( /** * 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. + * 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()) return + if (restored || !isFree() || !holdsPairing()) return restored = true val saved = try { store.load() @@ -326,7 +333,12 @@ class AidenBotCreateFlow( throw error } catch (_: Exception) { null - } ?: return + } + if (!holdsPairing()) { + restored = false + return + } + saved ?: return if (!isFree()) return val request = saved.request val key = saved.key @@ -351,6 +363,8 @@ class AidenBotCreateFlow( */ 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()) return null if (failed.replays) return start(failed.draft, null) prefill = failed.draft _failure.value = null @@ -403,20 +417,21 @@ fun aidenBotCreateWasTaken( */ fun AidenRemoteCoordinator.botCreateFlow(): AidenBotCreateFlow = pairingMemo("bot-create-flow") { val host = botSaveHost() - val store = installationStore.activeInstallation?.let { installation -> - val instanceId = installation.instanceId - val deviceId = installation.deviceId + val pairing = activePairing + val store = pairing?.let { AidenBotCreateStore( - load = { botSessionStore.pendingCreate(instanceId, deviceId) }, - save = { botSessionStore.savePendingCreate(instanceId, deviceId, it) }, - remove = { botSessionStore.removePendingCreate(instanceId, deviceId) } + 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 + store = store, + // With no pairing, the flow stands only while none is paired (its creates can't reach a Mac). + holdsPairing = { activePairing == pairing } ) } 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 index 0ce73e830..cc62961f4 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSaves.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSaves.kt @@ -9,6 +9,7 @@ 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 @@ -46,8 +47,8 @@ fun AidenRemoteCoordinator.botSaveHost(): AidenBotSaveHost = pairingMemo("bot-sa val pairing = activePairing fun samePairing() = holdsPairing(pairing) AidenBotSaveHost( - client = { if (samePairing()) client.value else null }, - waitForConnection = { samePairing() && aidenBotAwaitConnected(connectionState, client) && samePairing() }, + client = { pairedClient(pairing) }, + waitForConnection = { samePairing() && aidenBotAwaitConnected(connectionState, client.map { pairedClient(pairing) }) && samePairing() }, launch = { block -> launchDetached(block) }, show = pinnedBotShow(), cache = pinnedBotCache(), 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 index 5da1e7910..a9de7b9ab 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt @@ -8,6 +8,7 @@ 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 @@ -168,10 +169,10 @@ class AidenBotSessionStore( private val owners = HashMap() /** Chats whose unreadable file couldn't be moved aside: it is never written over. */ private val stuck = HashSet() - /** Pairings (instance, device) 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. */ - private val revokedCreates = 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] @@ -275,7 +276,7 @@ class AidenBotSessionStore( outboxes.keys.removeAll { it.instanceId == instanceId } owners.keys.removeAll { it.instanceId == instanceId } stuck.removeAll { it.instanceId == instanceId } - revokedCreates += createPairings.filter { it.first == instanceId } + revokedCreates += createPairings.filter { it.instanceId == instanceId } val dir = root?.let { File(it, digest(instanceId)) } io.execute { dir?.deleteRecursively() } } @@ -283,14 +284,16 @@ class AidenBotSessionStore( // --- 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 + * 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. Its age is the caller's to judge (see [AidenBotCreateFlow]). + * `.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(instanceId: String, deviceId: String): AidenBotPendingCreate? = + suspend fun pendingCreate(pairing: AidenPairingIdentity): AidenBotPendingCreate? = suspendCancellableCoroutine { continuation -> - synchronized(this) { createPairings += instanceId to deviceId } - val task = Runnable { continuation.resumeWith(runCatching { loadCreate(instanceId, deviceId) }) } + synchronized(this) { createPairings += pairing } + val task = Runnable { continuation.resumeWith(runCatching { loadCreate(pairing) }) } try { io.execute(task) } catch (error: RejectedExecutionException) { @@ -305,19 +308,18 @@ class AidenBotSessionStore( * 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(instanceId: String, deviceId: String, create: AidenBotPendingCreate): Deferred { + fun savePendingCreate(pairing: AidenPairingIdentity, create: AidenBotPendingCreate): Deferred { val kept = CompletableDeferred() - val pairing = instanceId to deviceId synchronized(this) { createPairings += pairing if (pairing in revokedCreates) return kept.apply { complete(false) } } - val record = CreateRecord.of(instanceId, deviceId, create) + val record = CreateRecord.of(pairing, create) try { io.execute { var written = false try { - written = writeCreate(instanceId, deviceId, record) + written = writeCreate(pairing, record) } finally { kept.complete(written) } @@ -329,8 +331,8 @@ class AidenBotSessionStore( } /** The New Bot was made, let go of, or this phone lost access: nothing of it is kept. */ - fun removePendingCreate(instanceId: String, deviceId: String) { - io.execute { createFileFor(instanceId, deviceId)?.let { AtomicFile(it).delete() } } + fun removePendingCreate(pairing: AidenPairingIdentity) { + io.execute { createFileFor(pairing)?.let { AtomicFile(it).delete() } } } @Serializable @@ -338,6 +340,8 @@ class AidenBotSessionStore( 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, @@ -360,10 +364,11 @@ class AidenBotSessionStore( } companion object { - fun of(instanceId: String, deviceId: String, create: AidenBotPendingCreate) = CreateRecord( + fun of(pairing: AidenPairingIdentity, create: AidenBotPendingCreate) = CreateRecord( version = 1, - instanceId = instanceId, - deviceId = deviceId, + instanceId = pairing.instanceId, + deviceId = pairing.deviceId, + pairedAt = pairing.pairedAtMs, name = create.draft.name, help = create.draft.help, providerId = create.draft.model?.providerId, @@ -375,15 +380,25 @@ class AidenBotSessionStore( } } - private fun loadCreate(instanceId: String, deviceId: String): AidenBotPendingCreate? { - val file = createFileFor(instanceId, deviceId) ?: return null + private fun loadCreate(pairing: AidenPairingIdentity): AidenBotPendingCreate? { + val file = createFileFor(pairing) ?: return null if (!file.exists() && !File(file.path + ".bak").exists()) return null - val create = try { + 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 == instanceId && it.deviceId == deviceId } - ?.create() + .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 } @@ -400,9 +415,9 @@ class AidenBotSessionStore( * 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(instanceId: String, deviceId: String, record: CreateRecord): Boolean { - val file = createFileFor(instanceId, deviceId) ?: return true - if (synchronized(this) { (instanceId to deviceId) in revokedCreates }) return false + 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 { @@ -423,8 +438,8 @@ class AidenBotSessionStore( } /** Beside the pairing's chat outboxes (named by 64-character digests, so never this name), and purged with them. */ - internal fun createFileFor(instanceId: String, deviceId: String): File? = root?.let { - File(File(File(it, digest(instanceId)), digest(deviceId)), "create.json") + internal fun createFileFor(pairing: AidenPairingIdentity): File? = root?.let { + File(File(File(it, digest(pairing.instanceId)), digest(pairing.deviceId)), "create.json") } // --- Disk --- @@ -624,6 +639,9 @@ class AidenBotSessionStore( 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/AidenBotsHomeScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotsHomeScreen.kt index 8d6ca4b11..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 @@ -536,9 +536,13 @@ private fun AidenBotsHomeContent( text = { Text(stringResource(failure.message)) }, confirmButton = { TextButton(onClick = { - // An earlier launch's create goes out again as it was; otherwise New Bot reopens. + // 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() - if (again != null) createHost.launch { createFlow.send(again) } else onNavigateToCreateBot() + when { + again != null -> createHost.launch { createFlow.send(again) } + createFlow.reopening() != null -> onNavigateToCreateBot() + } }) { Text(stringResource(R.string.action_try_again), color = palette.accent) } }, dismissButton = { 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 00b7bb43f..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 @@ -94,7 +94,9 @@ class AidenRemoteCoordinator( private val intentCatalogStore: AidenIntentCatalogStore? = null, 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 + 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")) @@ -102,7 +104,9 @@ class AidenRemoteCoordinator( 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 = AidenBotSessionStore(File(botOutboxDir, "BotOutbox-v1")) + 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()) @@ -121,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() @@ -160,6 +167,16 @@ class AidenRemoteCoordinator( /** 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 @@ -262,6 +279,7 @@ class AidenRemoteCoordinator( val installation = installationStore.activeInstallation cancelDetachedWork { it != null && it == installation?.pairing } if (installation == null) { + clientOwner = null _client.value = null _serverInfo.value = null _workspaces.value = emptyList() @@ -272,6 +290,7 @@ class AidenRemoteCoordinator( val credential = installationStore.getCredential(installation) if (credential == null) { + clientOwner = null _client.value = null _serverInfo.value = null _workspaces.value = emptyList() @@ -282,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. 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 index 11949554d..ad3722801 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt @@ -21,12 +21,16 @@ 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 @@ -37,6 +41,7 @@ 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. */ @@ -549,6 +554,9 @@ class AidenBotOptimisticSavesTest { 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, @@ -562,9 +570,9 @@ class AidenBotOptimisticSavesTest { create = mac::create, cache = {}, store = AidenBotCreateStore( - load = { sessions.pendingCreate(pairing.instanceId, pairing.deviceId) }, - save = { sessions.savePendingCreate(pairing.instanceId, pairing.deviceId, it) }, - remove = { sessions.removePendingCreate(pairing.instanceId, pairing.deviceId) } + load = { sessions.pendingCreate(paired) }, + save = { sessions.savePendingCreate(paired, it) }, + remove = { sessions.removePendingCreate(paired) } ), nowMs = clock ) @@ -618,7 +626,7 @@ class AidenBotOptimisticSavesTest { /** The ask as the disk holds it now, read by a store of its own (a relaunch would read this). */ private suspend fun onDisk(root: java.io.File): AidenBotPendingCreate? = - AidenBotSessionStore(root, io = { it.run() }).pendingCreate(pairing.instanceId, pairing.deviceId) + AidenBotSessionStore(root, io = { it.run() }).pendingCreate(paired) @Test fun aCreateGoesOutOnlyOnceItsRequestAndKeyAreOnDiskAndADeathAfterTheMacTookItReplaysThem() = runTest { @@ -744,12 +752,104 @@ class AidenBotOptimisticSavesTest { // 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(pairing.instanceId, pairing.deviceId)?.draft?.name) - assertNull("another pairing has none", phone.botSessionStore.pendingCreate(pairing.instanceId, "device-other")) + 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(pairing.instanceId, pairing.deviceId)) - phone.botSessionStore.savePendingCreate(pairing.instanceId, pairing.deviceId, AidenBotPendingCreate(AidenBotCreateDraft(name = "Late"), createdAtMs = 0)) - assertNull("never written again after the unpair", phone.botSessionStore.pendingCreate(pairing.instanceId, pairing.deviceId)) + 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) } } From ba980b987f9ef4653370da75b964d1fd0697fdc6 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 00:42:40 -0400 Subject: [PATCH 72/83] Android Bots: an ambiguous New Bot is never replayed past the Mac's day The Mac keeps a settled Create key for 24 hours, and the same key after that makes a second Bot. A New Bot now replays its key only within 23 hours of when it began on the phone's clock (and not when the clock has moved back more than an hour), checked on restore, on Try Again from a prompt left open across the boundary, and for this launch's own uncertain attempts. Past it, the store forgets the key on disk while keeping the answers and request, so no later launch can replay it. Try Again then reopens New Bot with the answers and says the Bot may already be on the Mac; only the person's own Create sends a new request. Co-Authored-By: Claude Opus 5.5 --- .../features/bots/AidenBotEditorScreen.kt | 48 ++++++++----- .../features/bots/AidenBotOutbox.kt | 37 ++++++++-- android/app/src/main/res/values/strings.xml | 1 + .../bots/AidenBotOptimisticSavesTest.kt | 71 ++++++++++++++++--- 4 files changed, 128 insertions(+), 29 deletions(-) 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 011b63e42..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 @@ -109,7 +109,9 @@ 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 nowMs: () -> Long = System::currentTimeMillis, @@ -120,15 +122,23 @@ class AidenBotCreateAttempts( /** 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 and [draft] is unchanged, so Create replays it. */ - fun replays(draft: AidenBotCreateDraft): Boolean = uncertain?.draft == draft + /** 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(), nowMs()) @@ -179,8 +189,9 @@ class AidenBotCreateStore( * 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); after - * that, or when nothing could have reached the Mac, it reopens New Bot as it was. + * 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. @@ -241,7 +252,10 @@ class AidenBotCreateFlow( */ fun start(draft: AidenBotCreateDraft, catalog: AidenBotCapabilityCatalog?): AidenBotCreateAttempt? { if (_pending.value != null || !holdsPairing()) return null - val attempt = attempts.begin(draft, catalog) ?: 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 @@ -270,7 +284,7 @@ class AidenBotCreateFlow( 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.replays(attempt.draft) + val earlier = attempts.mayHaveArrived(attempt) _failure.value = Failure( R.string.bot_create_not_kept, attempt.draft, @@ -299,7 +313,7 @@ class AidenBotCreateFlow( return } attempts.failed(attempt, error) - val mayHaveArrived = attempts.replays(attempt.draft) + 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())) @@ -343,10 +357,11 @@ class AidenBotCreateFlow( 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 && nowMs() - saved.createdAtMs < AidenBotSessionStore.LIFETIME_MS + val replays = request != null && key != null && aidenBotCreateReplayable(saved.createdAtMs, nowMs()) if (replays) attempts.adopt(AidenBotCreateAttempt(saved.draft, request!!, key!!, saved.createdAtMs)) _failure.value = Failure( - R.string.bot_create_interrupted, + // 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, @@ -358,14 +373,15 @@ class AidenBotCreateFlow( /** * Try again. An earlier launch's create that may have reached the Mac goes out again as it - * was: the attempt to [send] comes back. Otherwise New Bot opens with the answers - * ([reopening]) and this returns null. + * 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()) return null - if (failed.replays) return start(failed.draft, null) + 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 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 index a9de7b9ab..70ef9a9d2 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOutbox.kt @@ -121,15 +121,32 @@ data class AidenBotOutboxRestore(val messages: List, va /** * 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 and its - * idempotency [key] (null when nothing that could reach the Mac was sent), and when it began. + * 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 { @@ -359,7 +376,6 @@ class AidenBotSessionStore( } val request = body?.let { AidenBotWireJson.json.decodeFromString(AidenBotCreateRequest.serializer(), it) } val idempotencyKey = key?.let(UUID::fromString) - require((request == null) == (idempotencyKey == null)) return AidenBotPendingCreate(AidenBotCreateDraft(name, help, route), request, idempotencyKey, createdAt) } @@ -406,6 +422,15 @@ class AidenBotSessionStore( 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 } @@ -628,6 +653,10 @@ class AidenBotSessionStore( 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 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 240828fd3..96d993655 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -715,6 +715,7 @@ 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? 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 index ad3722801..98e77e00f 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotOptimisticSavesTest.kt @@ -322,14 +322,22 @@ class AidenBotOptimisticSavesTest { // Bot create stays idempotent when its request times out (R15 bounds it). @Test - fun aCreateThatTimedOutReplaysItsKey() { + fun aCreateThatTimedOutReplaysItsKeyWhileTheMacKeepsIt() { val keys = ArrayDeque(List(2) { UUID.randomUUID() }) - val attempts = AidenBotCreateAttempts(makeKey = { keys.removeFirst() }) + 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) --- @@ -625,8 +633,8 @@ class AidenBotOptimisticSavesTest { } /** The ask as the disk holds it now, read by a store of its own (a relaunch would read this). */ - private suspend fun onDisk(root: java.io.File): AidenBotPendingCreate? = - AidenBotSessionStore(root, io = { it.run() }).pendingCreate(paired) + private suspend fun TestScope.onDisk(root: java.io.File): AidenBotPendingCreate? = + AidenBotSessionStore(root, io = { it.run() }, nowMs = { currentTime }).pendingCreate(paired) @Test fun aCreateGoesOutOnlyOnceItsRequestAndKeyAreOnDiskAndADeathAfterTheMacTookItReplaysThem() = runTest { @@ -688,8 +696,9 @@ class AidenBotOptimisticSavesTest { } @Test - fun aCutOffCreateWhoseKeyTheMacNoLongerKeepsReopensNewBotInstead() = runTest { + 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 @@ -697,13 +706,57 @@ class AidenBotOptimisticSavesTest { val first = before.start(draft, catalog)!! launch { before.send(first) }.also { runCurrent() }.cancel() - now = AidenBotSessionStore.LIFETIME_MS + 1 + // 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() - assertFalse("a day on, the same key could make a second Bot", after.failure.value!!.replays) - assertNull(after.retry()) + 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()) - assertFalse(after.start(draft, catalog)!!.key == first.key) + 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 From edfc8f05526b216aaff58aabb467cb58eb078d56 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 00:51:29 -0400 Subject: [PATCH 73/83] iOS Bots: a recovered New Bot stays on the Mac it was made for Mac A's restore could land after a switch to Mac B and show its prompt there, and Try Again built its request from the active pairing, so A's answers could become a new Bot on B. The create flow now keeps failures and retained attempts per pairing, Bots home shows only the active pairing's failure and Creating... row, and Try Again (and the New Bot it reopens) sends only to the failure's own Mac; otherwise the failure waits for that Mac. The restore is fenced like Chat activity's isCurrent: a look that lands after its task was cancelled, or after the request context (instance, device, activation generation) stopped being current, shows nothing and is looked for again when that Mac is active. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotCreateView.swift | 127 +++++++++++++----- .../Features/Bots/AidenBotsHomeView.swift | 60 ++++++--- .../AidenBotHomeProfileTests.swift | 114 +++++++++++++++- 3 files changed, 251 insertions(+), 50 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift index 437b0a336..dafe0b984 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift @@ -60,6 +60,10 @@ struct AidenBotCreateOperations { /// 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. +/// +/// 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 { @@ -67,11 +71,16 @@ final class AidenBotCreateFlow { 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 - let input: AidenBotCreateInput + 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 @@ -83,8 +92,19 @@ final class AidenBotCreateFlow { 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." + /// 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: it is another Mac's, and waits for that Mac. + case none + } + private(set) var pending: Pending? - var failure: Failure? + /// Each pairing's failed create, kept for that pairing alone. + private var failures: [String: Failure] = [:] private struct Attempt { let scope: String @@ -96,7 +116,8 @@ final class AidenBotCreateFlow { var restored = false } - @ObservationIgnored private var retained: Attempt? + /// 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? @@ -105,6 +126,8 @@ final class AidenBotCreateFlow { @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 = [] @@ -122,29 +145,58 @@ final class AidenBotCreateFlow { 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. + /// 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 { guard pending == nil else { return } + if let retrying, retrying.instanceId != operations.instanceId || retrying.deviceId != operations.deviceId { + // Another Mac is the active one now: nothing goes to it. The answers wait for their own Mac. + 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. + func retry(_ failure: Failure, instanceId: String, deviceId: String) -> 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 } + // An earlier launch's create: the same request, on the same key. + return failure.replays ? .send(failure.input) : .reopen(failure) + } + private func make( _ input: AidenBotCreateInput, operations: AidenBotCreateOperations, onCreated: @MainActor (AidenBotDetail) async -> Void ) async { let scope = operations.scope - failure = nil + failures[scope] = nil pending = Pending( id: UUID(), name: input.name.trimmingCharacters(in: .whitespacesAndNewlines), - purpose: input.purpose.trimmingCharacters(in: .whitespacesAndNewlines) + purpose: input.purpose.trimmingCharacters(in: .whitespacesAndNewlines), + instanceId: operations.instanceId, + deviceId: operations.deviceId ) Self.sending.insert(scope) defer { @@ -152,7 +204,7 @@ final class AidenBotCreateFlow { Self.sending.remove(scope) } // The same ask as a failed try: its frozen request and key, whatever the catalog says now. - let replay = retained.flatMap { $0.scope == scope && Self.isSameAsk($0, input) ? $0 : nil } + let replay = retained[scope].flatMap { Self.isSameAsk($0, input) ? $0 : nil } // On disk before anything goes out, so a relaunch never loses it. await persist( AidenBotPendingCreate(input: input, frozen: replay?.frozen, createdAt: replay?.createdAt ?? now()), @@ -160,11 +212,11 @@ final class AidenBotCreateFlow { ).value var sent = false do { - guard await operations.waitForConnection(connectionHold) else { - failure = makeFailure(Self.unreachable, input, operations) + // 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 } - guard operations.isCurrent() else { return } var attempt: Attempt if let replay { attempt = replay @@ -178,7 +230,7 @@ final class AidenBotCreateFlow { ) await persist(record(attempt), operations).value } - retained = attempt + retained[scope] = attempt sent = true let created: AidenBotDetail if let made = attempt.frozen.created { @@ -187,7 +239,7 @@ final class AidenBotCreateFlow { let frozen = attempt.frozen created = try await bounded { try await operations.create(frozen.request, frozen.key) } attempt.frozen.created = created - retained = attempt + retained[scope] = attempt persist(record(attempt), operations) } guard operations.isCurrent() else { return } @@ -202,45 +254,56 @@ final class AidenBotCreateFlow { } let authoritative = try await bounded { try await operations.readBot(created.id) } guard operations.isCurrent() else { return } - retained = nil + retained[scope] = nil persist(nil, operations) await onCreated(authoritative) } catch is CancellationError { return } catch { if await operations.handleRevocation(error) { - retained = nil + retained[scope] = nil persist(nil, operations) return } if Self.isAccessLoss(error) { // Access ended: nothing of this ask is kept on disk. - retained = nil + retained[scope] = nil persist(nil, operations) - } else if sent, retained?.frozen.created == nil, !aidenBotEditorCreateFailureIsAmbiguous(error) { + } 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 = nil + retained[scope] = nil persist(AidenBotPendingCreate(input: input, frozen: nil, createdAt: now()), operations) } - guard operations.isCurrent() else { return } - // A Bot already made, or a request that may have arrived, is tried again as it was. - failure = makeFailure(aidenBotFriendlyLoadError(error), input, 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. - func restore(instanceId: String, deviceId: String) async { + /// + /// `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.insert(scope).inserted else { return } + guard let store, isFree(for: scope), !restoredScopes.contains(scope) else { return } + let look = UUID() + restoring[scope] = look await writes?.value - guard let saved = await store.pendingCreate(instanceId: instanceId, deviceId: deviceId), - isFree(for: scope) else { return } + 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 = Attempt(scope: scope, input: saved.input, frozen: frozen, createdAt: saved.createdAt, restored: true) + retained[scope] = Attempt(scope: scope, input: saved.input, frozen: frozen, createdAt: saved.createdAt, restored: true) } - failure = Failure( + failures[scope] = Failure( message: Self.interrupted, input: saved.input, replays: saved.frozen != nil, @@ -254,21 +317,23 @@ final class AidenBotCreateFlow { /// can't make a second Bot. Returns the write, for tests. @discardableResult func discard(_ failure: Failure) -> Task? { - if self.failure == failure { self.failure = nil } - guard pending == nil, !Self.sending.contains(Self.scope(instanceId: failure.instanceId, deviceId: failure.deviceId)) else { return nil } + 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 == nil && failure == nil && retained?.scope != scope && !Self.sending.contains(scope) + 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 { - Failure( + let attempt = retained[operations.scope].flatMap { Self.isSameAsk($0, input) ? $0 : nil } + return Failure( message: message, input: input, - replays: retained.map { $0.scope == operations.scope && $0.restored } ?? false, + replays: attempt?.restored ?? false, instanceId: operations.instanceId, deviceId: operations.deviceId ) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift index d58a412ac..99c48b611 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift @@ -346,6 +346,23 @@ 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. + private var activeCreateFailure: AidenBotCreateFlow.Failure? { + 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 @@ -391,7 +408,8 @@ struct AidenBotsHomeView: View { switch sheet { case .create: AidenBotCreateView(coordinator: coordinator, initial: createPrefill?.input) { input in - startCreate(input) + // Reopened by Try Again: it goes only to the Mac it was for. + startCreate(input, retrying: createPrefill) } case let .profile(bot): AidenBotProfileView( @@ -427,19 +445,23 @@ struct AidenBotsHomeView: View { .alert( AidenBotCreateCopy.failedTitle, isPresented: Binding( - get: { createFlow.failure != nil }, - set: { if !$0 { createFlow.failure = nil } } + get: { activeCreateFailure != nil }, + // Both buttons settle the failure themselves; a switch of Mac only hides it. + set: { _ in } ), - presenting: createFlow.failure + presenting: activeCreateFailure ) { failure in Button("Try Again") { - createFlow.failure = nil - if failure.replays { + guard let pairing = activePairing else { return } + switch createFlow.retry(failure, instanceId: pairing.instanceId, deviceId: pairing.deviceId) { + case let .send(input): // An earlier launch's create: the same request, on the same key. - startCreate(failure.input) - } else { - createPrefill = failure + startCreate(input, retrying: failure) + case let .reopen(reopened): + createPrefill = reopened presentedSheet = .create + case .none: + break } } Button("OK", role: .cancel) { @@ -461,9 +483,12 @@ 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. - if loadID.isBotSurfaceActive, let installation = coordinator.installationStore.activeInstallation { - await createFlow.restore(instanceId: installation.id, deviceId: installation.deviceId) + // 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() } @@ -565,13 +590,13 @@ struct AidenBotsHomeView: View { @ViewBuilder private var content: some View { - if let pending = createFlow.pending { + if let pending = activePendingCreate { AidenBotPendingCreateRow(pending: pending) } switch contentState { case .loading: AidenBotHomeSkeletonView(reduceMotion: reduceMotion) - case .empty where createFlow.pending != nil: + case .empty where activePendingCreate != nil: EmptyView() case .empty where coordinator.connectionState == .connected && !presets.isEmpty: AidenBotMeetFirstBotView( @@ -712,9 +737,10 @@ struct AidenBotsHomeView: View { .padding(.vertical, 10) } - /// Makes the Bot New Bot asked for, after the sheet closed (R15). + /// 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) { + private func startCreate(_ input: AidenBotCreateInput, retrying: AidenBotCreateFlow.Failure? = nil) { guard let context = try? coordinator.requestContext() else { return } createPrefill = nil let coordinator = coordinator @@ -733,7 +759,7 @@ struct AidenBotsHomeView: View { handleRevocation: { error in await coordinator.handleCredentialRevocation(error, context: context) } ) Task { - await createFlow.submit(input, operations: operations) { created in + await createFlow.submit(input, operations: operations, retrying: retrying) { created in await load() openChat(for: AidenBotSummary(detail: created)) } diff --git a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift index a8241daac..44465daf1 100644 --- a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift @@ -502,6 +502,11 @@ final class AidenBotHomeProfileTests: XCTestCase { } } +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 @@ -694,11 +699,12 @@ final class AidenBotInstantEditTests: XCTestCase { 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: "mac_a", + instanceId: instanceId, deviceId: deviceId, usesDurableSession: durable, waitForConnection: { _ in connected }, @@ -888,7 +894,7 @@ final class AidenBotInstantEditTests: XCTestCase { let otherPhone = createFlow(root) await otherPhone.restore(instanceId: "mac_a", deviceId: "device_2") - XCTAssertNil(otherPhone.failure, "another pairing never sees it") + 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") @@ -904,6 +910,110 @@ final class AidenBotInstantEditTests: XCTestCase { XCTAssertNil(later.failure) } + /// Holds the store's next directory write, and with it every later call on that store. + private final class HoldingFileManager: FileManager, @unchecked Sendable { + private let lock = NSLock() + private var armed = false + private var holding = false + private let gate = DispatchSemaphore(value: 0) + + func arm() { lock.withLock { armed = true } } + var isHolding: Bool { lock.withLock { holding } } + func letGo() { gate.signal() } + + override func createDirectory( + at url: URL, + withIntermediateDirectories createIntermediates: Bool, + attributes: [FileAttributeKey: Any]? = nil + ) throws { + let holds = lock.withLock { () -> Bool in + guard armed else { return false } + armed = false + holding = true + return true + } + 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 testAPendingCreateGoesWithAccessAndWithAnUnpair() async throws { let mac = CreateMac() From 28599843a12ae7af69d49d8bbda09ce9ec3ae37c Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 00:56:08 -0400 Subject: [PATCH 74/83] iOS Bots: a lost New Bot is never sent again on a key the Mac forgot The Mac's idempotency ledger forgets a Create key 24 hours after it answers and then runs the same key again, but every restored frozen request replayed on Try Again however old it was, and the store kept an ambiguous record forever. As on Android, a frozen request now replays on its key only within a 23-hour window (an hour short of the Mac's, for the two clocks; a clock that went back counts as past it). Past it the store keeps the answers and what was sent but drops the keys; Try Again first looks in the Mac's list for a Bot with the same name and subtitle made since the request went out (allowing two minutes of clock skew) and lets the failure go quietly if it is there, and otherwise reopens New Bot with the answers, where Create is a new request. This holds for a prompt left open across the boundary and for an attempt kept in memory. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotCreateView.swift | 83 ++++++++++++-- .../Features/Bots/AidenBotSessionModel.swift | 18 ++- .../Features/Bots/AidenBotsHomeView.swift | 14 ++- .../AidenBotHomeProfileTests.swift | 104 +++++++++++++++++- 4 files changed, 202 insertions(+), 17 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift index dafe0b984..6d8867fa3 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift @@ -26,8 +26,25 @@ struct AidenBotPendingCreate: Codable, Equatable, Sendable { 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; the 24-hour clock starts here. + /// 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. @@ -87,6 +104,10 @@ final class AidenBotCreateFlow { /// 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." @@ -98,6 +119,8 @@ final class AidenBotCreateFlow { 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 } @@ -175,13 +198,41 @@ final class AidenBotCreateFlow { } /// 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. - func retry(_ failure: Failure, instanceId: String, deviceId: String) -> Retry { + /// 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 } - // An earlier launch's create: the same request, on the same key. - return failure.replays ? .send(failure.input) : .reopen(failure) + 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( @@ -203,8 +254,14 @@ final class AidenBotCreateFlow { pending = nil Self.sending.remove(scope) } - // The same ask as a failed try: its frozen request and key, whatever the catalog says now. - let replay = retained[scope].flatMap { Self.isSameAsk($0, input) ? $0 : nil } + // 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. await persist( AidenBotPendingCreate(input: input, frozen: replay?.frozen, createdAt: replay?.createdAt ?? now()), @@ -303,12 +360,17 @@ final class AidenBotCreateFlow { 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 + deviceId: deviceId, + sent: unanswered, + sentAt: unanswered == nil ? nil : saved.createdAt ) } @@ -330,12 +392,15 @@ final class AidenBotCreateFlow { /// 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 + deviceId: operations.deviceId, + sent: unanswered?.frozen.request, + sentAt: unanswered?.createdAt ) } diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index d68c66a8a..2c0eae1bc 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -740,9 +740,11 @@ actor AidenBotOutboxStore { let create: AidenBotPendingCreate } - /// The New Bot an earlier launch left on its way for this pairing, if any. One whose Bot - /// the Mac already made is the Mac's after 24 hours and is dropped; one not made stays, - /// so its answers are never silently gone. A file this build can't read is set aside. + /// 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) @@ -762,11 +764,17 @@ actor AidenBotOutboxStore { } return nil } - if envelope.create.frozen?.created != nil, envelope.create.createdAt <= now().addingTimeInterval(-Self.lifetime) { + 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 } - return envelope.create + 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. diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift index 99c48b611..bcb94df93 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift @@ -453,14 +453,19 @@ struct AidenBotsHomeView: View { ) { failure in Button("Try Again") { guard let pairing = activePairing else { return } - switch createFlow.retry(failure, instanceId: pairing.instanceId, deviceId: pairing.deviceId) { + 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 .none: + case .settled, .none: break } } @@ -737,6 +742,11 @@ 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 diff --git a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift index 44465daf1..e8d680c70 100644 --- a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift @@ -518,11 +518,24 @@ final class AidenBotInstantEditTests: XCTestCase { 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") throws -> AidenBotDetail { + 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 @@ -1065,6 +1078,95 @@ final class AidenBotInstantEditTests: XCTestCase { 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() From d7a9d5eca2a1bbc136380ddaaa8675a3d9056f08 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 01:00:09 -0400 Subject: [PATCH 75/83] iOS Bots: a New Bot that can't be written to disk is never sent The Android review asked for a disk-before-network barrier on Create. iOS already awaited the pending-create write before the request went out, for Create and Try Again alike, but it ignored whether the write succeeded, so a full disk or an unwritable folder still sent a mutation that a relaunch could not recover. savePendingCreate now reports whether the record is on disk, the flow's write chain carries that result, and a create (new or replayed) whose answers or keys couldn't be written stops before anything reaches the Mac, with "couldn't store this Bot" and Try Again. The test checks against a second store that each request's exact body and key are on disk as it leaves, and that nothing goes out when the write fails. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotCreateView.swift | 48 ++++++++----- .../Features/Bots/AidenBotSessionModel.swift | 10 ++- .../AidenBotHomeProfileTests.swift | 71 +++++++++++++++++-- 3 files changed, 102 insertions(+), 27 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift index 6d8867fa3..eaba5dbe5 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift @@ -76,7 +76,8 @@ struct AidenBotCreateOperations { /// /// 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. +/// 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 @@ -112,6 +113,7 @@ final class AidenBotCreateFlow { 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 { @@ -145,8 +147,8 @@ final class AidenBotCreateFlow { @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. - @ObservationIgnored private var writes: Task? + /// 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. @@ -194,7 +196,7 @@ final class AidenBotCreateFlow { return } await make(input, operations: operations, onCreated: onCreated) - await writes?.value + _ = await writes?.value } /// Try Again on `failure` while the pairing (`instanceId`, `deviceId`) is the active one. @@ -262,11 +264,14 @@ final class AidenBotCreateFlow { } // 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. - await persist( + // 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 + ).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. @@ -285,7 +290,11 @@ final class AidenBotCreateFlow { frozen: AidenBotPendingCreate.Frozen(request: request, draft: draft, key: UUID(), chatKey: UUID()), createdAt: now() ) - await persist(record(attempt), operations).value + // 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 @@ -349,7 +358,7 @@ final class AidenBotCreateFlow { guard let store, isFree(for: scope), !restoredScopes.contains(scope) else { return } let look = UUID() restoring[scope] = look - await writes?.value + _ = 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 } @@ -378,7 +387,7 @@ final class AidenBotCreateFlow { /// 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? { + 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 } @@ -409,23 +418,24 @@ final class AidenBotCreateFlow { } @discardableResult - private func persist(_ create: AidenBotPendingCreate?, _ operations: AidenBotCreateOperations) -> Task { + 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. + /// 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 { + private func persist(_ create: AidenBotPendingCreate?, instanceId: String, deviceId: String) -> Task { let previous = writes let store = store - let task = Task { - await previous?.value - guard let store else { return } - if let create { - await store.savePendingCreate(create, instanceId: instanceId, deviceId: deviceId) - } else { + 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 diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index 2c0eae1bc..1e1871dcb 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -777,19 +777,23 @@ actor AidenBotOutboxStore { return lapsed } - /// Keeps the pairing's New Bot on its way; never for a pairing an unpair purged. - func savePendingCreate(_ create: AidenBotPendingCreate, instanceId: String, deviceId: String) { + /// 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 + 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 } } diff --git a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift index e8d680c70..91b684715 100644 --- a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift @@ -705,6 +705,8 @@ final class AidenBotInstantEditTests: XCTestCase { var chatErrors: [Error] = [] var hold: CheckedContinuation? var holds = false + /// Runs as each create reaches the Mac. + var onCreate: (@MainActor () async -> Void)? } private func createOperations( @@ -724,6 +726,7 @@ final class AidenBotInstantEditTests: XCTestCase { 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) @@ -916,35 +919,39 @@ final class AidenBotInstantEditTests: XCTestCase { 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 + _ = 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. + /// 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 = lock.withLock { () -> Bool in - guard armed else { return false } + let (holds, refuses) = lock.withLock { () -> (Bool, Bool) in + guard armed else { return (false, refusing) } armed = false holding = true - return true + return (true, refusing) } + if refuses { throw CocoaError(.fileWriteOutOfSpace) } if holds { gate.wait() } try super.createDirectory(at: url, withIntermediateDirectories: createIntermediates, attributes: attributes) } @@ -1027,6 +1034,60 @@ final class AidenBotInstantEditTests: XCTestCase { 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 testAPendingCreateGoesWithAccessAndWithAnUnpair() async throws { let mac = CreateMac() From 492eb1ac6aa9853897df5f0ecfc6d681adec0904 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 01:02:24 -0400 Subject: [PATCH 76/83] iOS Bots: a Try Again while another New Bot is on its way keeps its answers With failures kept per pairing, Mac B's interrupted create can come back while a Bot is still on its way to Mac A. Its Try Again then reached submit's one-at-a-time guard and returned, dropping B's failure from memory. A Try Again (or the New Bot it reopened) that can't go out now keeps its answers as they were, for the same pairing or another, and Bots home holds the failure prompt until no Bot is on its way. Co-Authored-By: Claude Opus 5.5 --- .../Features/Bots/AidenBotCreateView.swift | 19 +++++---- .../Features/Bots/AidenBotsHomeView.swift | 6 ++- .../AidenBotHomeProfileTests.swift | 39 +++++++++++++++++++ 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift index eaba5dbe5..edf93348c 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotCreateView.swift @@ -184,14 +184,17 @@ final class AidenBotCreateFlow { retrying: Failure? = nil, onCreated: @MainActor (AidenBotDetail) async -> Void ) async { - guard pending == nil else { return } - if let retrying, retrying.instanceId != operations.instanceId || retrying.deviceId != operations.deviceId { - // Another Mac is the active one now: nothing goes to it. The answers wait for their own Mac. - let scope = Self.scope(instanceId: retrying.instanceId, deviceId: retrying.deviceId) - if failures[scope] == nil { - var kept = retrying - kept.input = input - failures[scope] = kept + 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 } diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift index bcb94df93..af4223380 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotsHomeView.swift @@ -358,9 +358,11 @@ struct AidenBotsHomeView: View { return pending } - /// The failed create of the Mac in use; another Mac's waits for that Mac. + /// 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? { - activePairing.flatMap { createFlow.failure(instanceId: $0.instanceId, deviceId: $0.deviceId) } + 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. diff --git a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift index 91b684715..a74fb80b7 100644 --- a/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotHomeProfileTests.swift @@ -1088,6 +1088,45 @@ final class AidenBotInstantEditTests: XCTestCase { 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() From 69f5c2f1f390bb22d769a9465149731c9dc8fec7 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 02:18:55 -0400 Subject: [PATCH 77/83] Chat: a run this window started is never mirrored back to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revisiting a running chat starts the local run watcher. When the route-detached run then ended (Stop, or its reply landed), the watcher reported the run's end after the terminal had already cleared the detached marker, and that marker was the only proof the run was this window's own. The run came back as an observed (phone-style) run, so the composer re-locked with "Waiting for the response…" right after it had unlocked, and a text-only reply could flash a second streaming row and Stop. Remember the streams this document starts (bounded) and never mirror them. Co-Authored-By: Claude Opus 5.5 --- renderer/lib/chat-terminal-sync.test.ts | 59 +++++++++++++++++++++++++ renderer/lib/chat-terminal-sync.ts | 26 ++++++++++- renderer/lib/ipc.ts | 2 + 3 files changed, 86 insertions(+), 1 deletion(-) 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 }, From 4813a5c7e85f6d4b0862b91539b977aa9c115f42 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 02:19:06 -0400 Subject: [PATCH 78/83] Chat: Steer and Queue work while a response is still starting Stop and the Steer/Queue pill appear as soon as a send begins, but two things made the pill unreliable until main had finished starting the run: - handleSend awaited the chat:start acknowledgement, which kept the composer in its sending state. The pill could not submit, so picking Steer or Queue only switched the mode and left the draft. The send now finishes at the durable append; a start rejection still sets the error. - Pi opens its run-input queue at agent_start. Before that main answers run_not_active, which the composer reported as "The current response has ended". Admission now retries that answer, with a bound, while the pane's own run is still live. A rejected attempt writes nothing, so a retry cannot duplicate guidance. Detached runs are not retried. Under a six-worker load the Steer specs in chat-message-queue failed 8 of 30 runs before and 0 of 60 after. Co-Authored-By: Claude Opus 5.5 --- renderer/lib/chat-message-queue.test.ts | 71 +++++++++++++++++++++++++ renderer/lib/chat-message-queue.ts | 33 ++++++++++++ renderer/main/chat-pane.tsx | 24 ++++++--- 3 files changed, 122 insertions(+), 6 deletions(-) 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/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) => From a702a265b0ac1303a478380f2e6361897aa36cf6 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 02:19:06 -0400 Subject: [PATCH 79/83] E2E: chat queue specs report what failed upstream The two hosted flakes in chat-message-queue (Stop missing after a revisit; no request with the edited queue text) predate Bot slice C2 and have not reproduced locally or in a Linux container. Make the next hosted failure explain itself: - On failure the fixture prints the on-screen alerts, status lines and toasts, plus the dev log's warn and error records, to stderr. Hosted artifacts keep only the sanitized receipt, and the reporter truncates the dev-log attachment to two lines. - The queue-editor spec checks that the first reply saved and the queue is not paused before it saves the edit. A response that failed upstream now fails at that step instead of reading as an editor bug. - The rejected-steer spec waits for the closing menu to return focus to its trigger before typing. Radix restores that focus a task later, and under load an Enter sent straight to the composer could land on the trigger and reopen the menu. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/chat-message-queue.spec.ts | 12 +++++++ tests/e2e/fixtures.ts | 52 +++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/tests/e2e/chat-message-queue.spec.ts b/tests/e2e/chat-message-queue.spec.ts index 0066707a1..101adf799 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); 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`); From f7b919cb4e503574b61cb8f8b5dfefcf94a77f05 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 02:19:12 -0400 Subject: [PATCH 80/83] Memory: chat-message-queue flake investigation and fixes Co-Authored-By: Claude Opus 5.5 --- .memory/revisited-generation-controls.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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. From 22bc147d55ab16ee065b2d945f82089539edc77b Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 02:29:31 -0400 Subject: [PATCH 81/83] Pi: a Stop-interrupted journal write is not logged as a failure On Stop the harness reports each journal write it stops waiting for to onJournalError as a PiManagedCancellationError, so every Stop logged "Could not append Pi session messages" at error level, usually three times. The write is detached and may still land, but the visible-turn lease rolls the turn back and re-journals the visible chat, so the session stays intact. Keep marking the journal unhealthy, log the Stop once at info, and keep real write failures at error. Co-Authored-By: Claude Opus 5.5 --- main/services/llm-client.ts | 14 +++ .../services/pi-agent-runtime-harness.test.ts | 108 +++++++++++++++++- main/services/pi-agent-runtime-harness.ts | 9 ++ 3 files changed, 129 insertions(+), 2 deletions(-) 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)); } From fee9ae38bb17e65f7855b58f0c90a7a3ef6271f7 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 02:30:28 -0400 Subject: [PATCH 82/83] Chat transition test: follow the send path now that generation starts without awaiting Co-Authored-By: Claude Opus 5.5 --- renderer/main/chat-transition.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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); From f7f8284664613ec037e520fc71aeb4327bf5be97 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 15 Oct 2026 03:17:31 -0400 Subject: [PATCH 83/83] E2E: queued image follow-ups are held through each phase of a starting send 4813a5c7e settles a send once its message is saved, so Steer and Queue work while main is still starting the response. The image-queue spec held only the start receipt and expected Attach to stay disabled through it. That expectation followed from the old send settling at the start acknowledgement. Locally it still passed because its first check landed before the save finished. On Linux CI the save was already done, and it failed both attempts (run 38692828176). The attach gate exists so that a failed send can restore its own payload without merging in newly added files. Once the message is saved, a start failure no longer restores anything, so intake may reopen during start like Steer and Queue. The spec now holds the save receipt and then the start receipt. Attach is disabled until the save lands and enabled while the start is still held. The image and text are queued in that window, and Stop still pauses both without sending either. Each phase is now deterministic: the old send path fails the new check every time. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/chat-message-queue.spec.ts | 39 +++++++++++++++++----------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/tests/e2e/chat-message-queue.spec.ts b/tests/e2e/chat-message-queue.spec.ts index 101adf799..075fbd901 100644 --- a/tests/e2e/chat-message-queue.spec.ts +++ b/tests/e2e/chat-message-queue.spec.ts @@ -643,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 = ( @@ -692,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");