diff --git a/App.tsx b/App.tsx index db26af5..de27c3d 100644 --- a/App.tsx +++ b/App.tsx @@ -73,6 +73,12 @@ function MooseApp() { const [history, setHistory] = useState(false); const [artifactsVisible, setArtifactsVisible] = useState(false); const [keyboardVisible, setKeyboardVisible] = useState(false); + useEffect(() => { + if (!moose.notificationNavigation) return; + setTab("chat"); + setHistory(false); + setArtifactsVisible(false); + }, [moose.notificationNavigation]); const followBottom = useRef(true); const list = useRef>(null); const activityMessages = groupActivities(moose.messages, moose.activity); diff --git a/README.md b/README.md index 3a57a6e..4ec962e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Moose -Moose v0.7.0 is an open source AI agent for iOS and Android powered by Pi Durable. Expo / React Native provides the interface, Pi Durable runs on the phone's Hermes runtime, and SQLite stores conversations and tasks. Model requests go directly to the provider; no Moose backend is required. +Moose v0.7.1 is an open source AI agent for iOS and Android powered by Pi Durable. Expo / React Native provides the interface, Pi Durable runs on the phone's Hermes runtime, and SQLite stores conversations and tasks. Model requests go directly to the provider; no Moose backend is required. ## Features @@ -272,6 +272,8 @@ Chat shows the current task separately from historical reply attempts: running, Transient model failures use Pi's durable retry checkpoint, with at most three generation retries and exponential 2/4/8-second backoff. Reopening projects committed messages and tool results and reconciles already-settled outbox items; it does not resubmit a task on foreground return. A failed attempt that later recovered is labelled as history. Network waiting is inferred from a transport error with a scheduled retry, rather than a claim that connectivity has been measured. Authentication and quota errors are not retried automatically. An operation whose effects are unknown blocks further mutations across restart and context compaction; inspect its effects before choosing **Review interrupted operation → Allow further changes**. That action does not rerun the command. See [background recovery design and validation](docs/background-recovery.md). +Android’s ongoing notification shows committed execution progress. Background completion or failure posts one result alert per conversation; foreground results are silent. Tap a single-task or result notification to open its saved conversation without resubmitting it. Multiple active conversations share a count notification and the existing Stop-all action. Failure alerts use brief fixed categories without provider bodies or credentials. Task results can be muted separately in Android notification settings. + System reclamation, Doze, network loss and vendor power policies can still interrupt execution. Process death cannot preserve the original TCP connection or Promise, and recovery cannot guarantee a model is billed only once. Force-stop is respected. iOS retains its existing suspension behavior. The Android `specialUse` declaration describes finite AI work; this does not imply Google Play approval. For a controlled native probe, rebuild the Android development client and run `EXPO_PUBLIC_MOOSE_BACKGROUND_SMOKE=1 npx expo start --dev-client`. Tap Run background probe, confirm the service starts, then press Home. After 15 seconds the probe should finish its file tool and second model round, commit SQLite and release its notification. The probe uses a separate database and controlled SSE, never real credentials or paid requests. Notification stopping can be checked during the delay. The same probe has a five/ten-minute lock test and a durable-results reader. For actual native network transport, start `node scripts/background-probe-server.mjs` on the development machine, then use **Run network probe** in the Android emulator; it connects only to the controlled local endpoint through `10.0.2.2:8106`. No request is forwarded to a model service. diff --git a/app.json b/app.json index 2a0b224..6656117 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Moose", "slug": "moose-mobile", - "version": "0.7.0", + "version": "0.7.1", "scheme": "moose", "orientation": "default", "userInterfaceStyle": "light", @@ -15,7 +15,7 @@ }, "android": { "package": "dev.verysmallwoods.moose", - "versionCode": 11 + "versionCode": 20 }, "plugins": [ "expo-sqlite", @@ -32,7 +32,8 @@ ], "./plugins/termux-network.cjs", "expo-sharing", - "./plugins/termux-setup.cjs" + "./plugins/termux-setup.cjs", + "./plugins/notification-intent.cjs" ], "web": { "bundler": "metro" diff --git a/docs/background-recovery.md b/docs/background-recovery.md index 9e83a31..2ea2ba9 100644 --- a/docs/background-recovery.md +++ b/docs/background-recovery.md @@ -45,3 +45,30 @@ To repeat: start the opt-in probe from README with a freshly loaded development Native UI inspection used the opt-in preview with the actual Markdown component: recovered attempts used neutral text, terminal stopped attempts used red text, details expanded to show error/time/phase/retries, and task details opened a native alert. This was a controlled component preview, not a real-account chat. Delivery checks: `npm run verify` passed with 226 tests and 8 existing skipped watcher cases, 3 Termux bootstrap tests, type checking, portable runtime smoke and Android/iOS Hermes exports. Android `assembleDebug` succeeded and that client was used for the native cases above. Independent reviews found and verified fixes for compacted-context replay protection, lifecycle-event association, cancelled retry counts and acknowledgement races. No native iOS build was performed. + +## Task notifications + +Android keeps one ongoing notification for the shared execution service. During admitted work it observes committed conversation views to show running, retrying, network retry waiting, or authorization waiting. A single conversation links directly to its chat; multiple active conversations show a count and open Moose without assigning the shared service to the wrong chat. Stop still cancels all tasks, and native stop causes and acknowledgement are unchanged. + +After settlement, Moose rereads the final committed view before releasing the service. A changed completed or failed outcome posts one result notification per conversation on the **Task results** channel; overlapping leases are deduplicated. A final authorization error counts as a failed result, while an in-progress permission prompt remains waiting. Cancelled tasks do not alert. Unchanged historical outcomes never alert when reopening or retrying an already-settled request. Each repeat iteration is an admitted execution and may produce a result; sleeping intervals do not hold a service. + +Result notifications are suppressed when the native Activity is resumed. Failure text uses fixed categories (credentials, network, usage limit, or generic failure) without copying provider bodies or user content. Tapping a result selects the existing persisted conversation through `moose://conversation/`, including a cold launch; the route never submits a request. Invalid or deleted conversation targets are ignored. + +Notifications remain required for Android admission. Denying permission prevents work from starting with an explicit error; losing permission retains native cancellation semantics. Muting the separate Task results channel silences results without changing task persistence. No notification permission or new background guarantee is added on iOS. This feature adds no executor, scheduler, or background wakeup. + +### Notification acceptance, 2026-10-23 + +- `npm run verify` passed: 239 tests, 8 skipped, 3 real bootstrap-script tests, strict types, portable Pi/SQLite smoke, and Android/iOS Hermes exports. A first bootstrap process-lock test timed out; the standalone rerun and complete subsequent verify passed without changing that test. Final `npm test` again passed 239/8. Expo Doctor passed 20/20 and modified files passed Prettier. +- Android API 35 arm64 debug APK compiled and installed. The native compile caught a `Context`/`ReactContext` mismatch in the new bridge; the explicit checked cast was corrected and both subsequent native builds passed. Initial dependency-cache/download failures were resolved by fetching the missing dependencies; no dependency downgrade or source workaround was added. +- Controlled on-device SSE parsing, file write, second model round and SQLite settlement completed while Home was visible. The running notification was replaced by a conversation-specific completed result. A controlled terminal error containing a fixture token produced only the fixed generic failure notification. Existing historical failures produced no startup alerts. +- A controlled network failure displayed **Waiting for network. Retry scheduled.**, recovered in the same execution, then posted a completed result. Two simultaneous conversations shared one count notification with an untargeted app link, then produced distinct result tags for conversations 247 and 253; the service released after the last run. +- Tapping the actual notification **Stop** action cancelled two active probes (295 and 301), persisted `notification_stop` for both, released the service and produced no result alerts. Denied notification permission showed **Allow notifications to start background tasks** and did not reach model transport. Restoring permission allowed the next controlled run. +- Foreground failure (189) and foreground completion (335) produced no result notifications. A real single failure notification switched the already-running normal UI from another chat to saved conversation 367, without submitting work. Android's automatically generated collapsed group summary opens the app without choosing a conversation; individual task notifications retain their own targets. + +These runs used isolated controlled model responses and SQLite fixtures, without real credentials or paid model calls. The emulator's unrelated saved Termux selection caused its existing connection error in the normal UI; this did not prevent reading or selecting the committed chat. No new Xiaomi/physical-device, Doze, full-budget, iOS native execution, or real account inference claim is made. + +The standalone cold-route regression initially opened saved chat 367 instead of requested chat 379 after process death with a retained recent task. `plugins/notification-intent.cjs` now preserves the latest Activity intent before React's `onNewIntent` delegate runs; this keeps bootstrap URL lookup correct when the link arrives before JavaScript is ready. `scripts/notification-route-smoke.mjs` failed on the original standalone APK and passed after the fix (367 → 379, no execution service). This test ran offline and used the exact action/category/component/flags of the task notification's activity intent. Warm routing used a real app notification tap; cold routing used the equivalent activity intent because the development launcher intercepted the cold notification test. Neither is presented as physical-device validation. + +To repeat the cold regression on a release APK with the controlled BackgroundSmoke database selected, run `ADB=/path/to/adb node scripts/notification-route-smoke.mjs 367 379`, substituting two known controlled probe IDs. The script requires the source fixture on screen and no active execution service before ending the process, retains the recent task, and verifies that the requested saved probe appears after cold launch. + +After rebasing onto the independently merged iOS iSH integration (`78eaeea`), the complete verification passed again: 245 tests passed, 37 skipped (including the environment-gated embedded iSH suite), 3 bootstrap tests passed, portable smoke and both Hermes exports passed, and Expo Doctor passed 20/20. No iSH native or iOS background-execution validation is claimed by this notification change. diff --git a/docs/superpowers/plans/2026-10-23-background-notifications.md b/docs/superpowers/plans/2026-10-23-background-notifications.md new file mode 100644 index 0000000..9ca16cb --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-background-notifications.md @@ -0,0 +1,21 @@ +# Background task notifications implementation plan + +**Goal:** Show committed Android task progress and one result alert, with conversation routing. +**Architecture:** Extend the existing execution guard with conversation IDs. A small observer watches committed views during admitted work and reads the final view before releasing the existing service. Native code keeps the shared ongoing notification and posts per-conversation results on a separate alert channel. No executor or scheduler is added. +**Tech stack:** Pi Durable, Expo module, Android NotificationCompat, React Native Linking. + +## Constraints and acceptance + +- Preserve service admission, notification permission, Stop-all, cancellation acknowledgement and 30-minute budget. +- Observe only admitted work; an unchanged historical outcome cannot alert. Deduplicate overlapping leases for one conversation. +- Progress uses persisted state. Results are read after settlement before service release. Never include provider bodies or user text in notifications; failures use fixed categories. +- Multiple active conversations show a count without a misleading conversation target. Each completed conversation gets its own result target. +- Suppress result alerts while the Activity is resumed. Denied permission retains existing admission failure and loss cancellation semantics. +- Warm and cold URL handling validates an existing conversation and only selects it; no submission occurs. iOS gains no background guarantee. + +## Work + +- [x] Write failing observer tests covering persisted final reads, old failures, retries, overlapping leases, cancellation and bounded errors; implement the observer and guard ID propagation. +- [x] Extend native progress/result methods and notification intents, preserving stop causes. Add validated warm/cold conversation selection. +- [x] Run complete verify, native compile and controlled emulator probes; record evidence and limits. +- [ ] Review diff, create PR, fix findings/checks, merge; increment actual latest patch version, tag and inspect release APK/manifest/signature/companion. diff --git a/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseBackgroundModule.kt b/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseBackgroundModule.kt index 7a32dea..be4b9fd 100644 --- a/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseBackgroundModule.kt +++ b/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseBackgroundModule.kt @@ -28,7 +28,11 @@ class MooseBackgroundModule : Module() { Function("ready") { token: String -> MooseExecutionService.instance?.ready(token) } AsyncFunction("stop") { token: String -> MooseExecutionService.releaseRequest(token) } .runOnQueue(expo.modules.kotlin.functions.Queues.MAIN) - Function("waiting") { value: Boolean -> MooseExecutionService.instance?.waiting(value) } + Function("waiting") { id: Double, value: Boolean -> MooseExecutionService.instance?.waiting(id.toLong(), value) } + Function("progress") { items: List> -> MooseExecutionService.instance?.progress(items) } + AsyncFunction("result") { id: Double, failed: Boolean, detail: String -> + MooseExecutionService.result(requireNotNull(appContext.reactContext as? com.facebook.react.bridge.ReactContext), id.toLong(), failed, detail) + }.runOnQueue(expo.modules.kotlin.functions.Queues.MAIN) Function("pendingStop") { MooseExecutionService.pendingStop(requireNotNull(appContext.reactContext)) } Function("stopDetail") { MooseExecutionService.stopDetail(requireNotNull(appContext.reactContext)) } Function("acknowledgeStop") { diff --git a/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseExecutionService.kt b/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseExecutionService.kt index 5feeb1a..d323e1c 100644 --- a/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseExecutionService.kt +++ b/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseExecutionService.kt @@ -1,12 +1,12 @@ package dev.verysmallwoods.background import android.app.* -import android.Manifest -import android.content.pm.PackageManager import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.os.* +import android.net.Uri +import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat @@ -25,6 +25,8 @@ class MooseExecutionService : Service() { private var lock: PowerManager.WakeLock? = null private var stopping = false private var notification: NotificationCompat.Builder? = null + private var progressItems: List> = emptyList() + private val approvals = mutableSetOf() private val budget = Runnable { cancel("budget_expired") } private val handshake = Runnable { cancel("handshake_timeout") } private val unresponsive = Runnable { @@ -60,7 +62,7 @@ class MooseExecutionService : Service() { val stop = PendingIntent.getService(this, 1, Intent(this, MooseExecutionService::class.java).setAction(ACTION_STOP).putExtra("token", requested), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val launch = packageManager.getLaunchIntentForPackage(packageName) + val launch = conversationIntent(this, null) val notification = NotificationCompat.Builder(this, CHANNEL) .setSmallIcon(android.R.drawable.stat_notify_sync) .setContentTitle("Moose is running tasks") @@ -68,7 +70,7 @@ class MooseExecutionService : Service() { .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setOngoing(true).setOnlyAlertOnce(true) .addAction(android.R.drawable.ic_media_pause, "Stop", stop) - if (launch != null) notification.setContentIntent(PendingIntent.getActivity(this, 0, launch, PendingIntent.FLAG_IMMUTABLE)) + notification.setContentIntent(launch) this.notification = notification ServiceCompat.startForeground(this, 7301, notification.build(), if (Build.VERSION.SDK_INT >= 34) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0) @@ -87,17 +89,44 @@ class MooseExecutionService : Service() { } return START_NOT_STICKY } - fun waiting(value: Boolean) { + fun waiting(id: Long, value: Boolean) { handler.post { - val builder = notification ?: return@post - if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - cancel("notification_permission_lost") - return@post - } - if (!stopping) getSystemService(NotificationManager::class.java).notify(7301, - builder.setContentText(if (value) "Approval needed. Open Moose to continue." else "Up to 30 minutes. Stop cancels all tasks.").build()) + if (value) approvals.add(id) else approvals.remove(id) + renderProgress() + } + } + fun progress(items: List>) { + handler.post { + progressItems = items + renderProgress() } } + private fun renderProgress() { + val builder = notification ?: return + if (!NotificationManagerCompat.from(this).areNotificationsEnabled() || + (Build.VERSION.SDK_INT >= 26 && getSystemService(NotificationManager::class.java).getNotificationChannel(CHANNEL)?.importance == NotificationManager.IMPORTANCE_NONE)) { + cancel("notification_permission_lost") + return + } + if (stopping) return + val single = progressItems.singleOrNull() + val id = (single?.get("id") as? Number)?.toLong() + val state = if (id != null && approvals.contains(id)) "waiting_authorization" else single?.get("state") as? String + val waiting = progressItems.count { (it["id"] as? Number)?.toLong() in approvals || it["state"] == "waiting_authorization" } + val retrying = progressItems.count { it["state"] == "retrying" || it["state"] == "waiting_network" } + val text = when { + progressItems.size > 1 -> "${progressItems.size} tasks active · $retrying retrying · $waiting need approval. Stop cancels all." + state == "waiting_network" -> "Waiting for network. Retry scheduled." + state == "retrying" -> "Retrying task." + state == "waiting_authorization" -> "Approval needed. Open Moose to continue." + else -> "Running. Stop cancels all tasks." + } + try { + getSystemService(NotificationManager::class.java).notify(7301, + builder.setContentTitle(if (progressItems.size > 1) "Moose is running tasks" else "Moose task") + .setContentText(text).setContentIntent(conversationIntent(this, id)).build()) + } catch (_: SecurityException) { cancel("notification_permission_lost") } + } fun ready(value: String) { handler.post { if (token == value && !stopping) { @@ -145,6 +174,32 @@ class MooseExecutionService : Service() { } companion object { private const val CHANNEL = "moose-execution" + private const val RESULTS = "moose-task-results" + private fun conversationIntent(context: Context, id: Long?): PendingIntent? { + val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null + launch.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + if (id != null) launch.action = Intent.ACTION_VIEW + if (id != null) launch.data = Uri.parse("moose://conversation/$id") + return PendingIntent.getActivity(context, if (id == null) 0 else (id % Int.MAX_VALUE).toInt(), launch, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + fun result(context: ReactContext, id: Long, failed: Boolean, detail: String) { + // Lifecycle is checked at delivery on the main queue, including warm Activity returns. + if (context.lifecycleState == com.facebook.react.common.LifecycleState.RESUMED || pendingStop(context)) return + if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return + val manager = context.getSystemService(NotificationManager::class.java) + if (Build.VERSION.SDK_INT >= 26) manager.createNotificationChannel( + NotificationChannel(RESULTS, "Task results", NotificationManager.IMPORTANCE_DEFAULT)) + val builder = NotificationCompat.Builder(context, RESULTS) + .setSmallIcon(if (failed) android.R.drawable.stat_notify_error else android.R.drawable.stat_notify_sync_noanim) + .setContentTitle(if (failed) "Moose task failed" else "Moose task completed") + .setContentText(if (failed) detail else "Open Moose to read the result.") + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setAutoCancel(true).setOnlyAlertOnce(false) + .setDefaults(NotificationCompat.DEFAULT_SOUND) + .setContentIntent(conversationIntent(context, id)) + manager.notify("conversation:$id", 7302, builder.build()) + } private const val PREFS = "moose-execution-control" private const val ACTION_STOP = "dev.verysmallwoods.moose.STOP_EXECUTION" private const val BUDGET = 30L * 60 * 1000 diff --git a/package-lock.json b/package-lock.json index ecc57e9..1e7e2da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "moose-mobile", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "moose-mobile", - "version": "0.7.0", + "version": "0.7.1", "dependencies": { "@earendil-works/chord": "1.3.0", "@earendil-works/pi-ai": "1.3.0", diff --git a/package.json b/package.json index 11245c7..c83e1b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moose-mobile", - "version": "0.7.0", + "version": "0.7.1", "private": true, "main": "index.ts", "scripts": { diff --git a/plugins/notification-intent.cjs b/plugins/notification-intent.cjs new file mode 100644 index 0000000..fcfcb2b --- /dev/null +++ b/plugins/notification-intent.cjs @@ -0,0 +1,28 @@ +const { withMainActivity } = require("expo/config-plugins"); + +module.exports = function withNotificationIntent(config) { + return withMainActivity(config, (result) => { + if (result.modResults.language !== "kt") + throw new Error("Moose notification links require the Kotlin Activity"); + const marker = "// Retain links delivered before React Native is ready."; + const source = result.modResults.contents; + if (source.includes(marker)) return result; + const anchor = " override fun onCreate("; + if ( + !source.includes(anchor) || + source.includes("override fun onNewIntent(") + ) + throw new Error("Review MainActivity intent handling before prebuilding"); + result.modResults.contents = source.replace( + anchor, + ` override fun onNewIntent(intent: android.content.Intent) { + ${marker} + setIntent(intent) + super.onNewIntent(intent) + } + +${anchor}`, + ); + return result; + }); +}; diff --git a/scripts/notification-route-smoke.mjs b/scripts/notification-route-smoke.mjs new file mode 100644 index 0000000..f053c91 --- /dev/null +++ b/scripts/notification-route-smoke.mjs @@ -0,0 +1,76 @@ +/** Opt-in emulator regression: requires the isolated BackgroundSmoke SQLite fixture. */ +import { execFileSync } from "node:child_process"; +import { setTimeout as delay } from "node:timers/promises"; +const [source, target] = process.argv.slice(2); +if ( + ![source, target].every((id) => /^[1-9]\d*$/.test(id ?? "")) || + source === target +) + throw new Error("Provide two distinct controlled probe conversation IDs"); +const adb = (...args) => + execFileSync(process.env.ADB ?? "adb", args, { + encoding: "utf8", + timeout: 15000, + }); +const shell = (...args) => adb("shell", ...args); +const app = "dev.verysmallwoods.moose"; +function launch(id) { + shell( + "am", + "start", + "-a", + "android.intent.action.VIEW", + "-c", + "android.intent.category.LAUNCHER", + "-n", + `${app}/.MainActivity`, + "-d", + `moose://conversation/${id}`, + "-f", + "0x24000000", + ); +} +async function shows(id) { + for (let n = 0; n < 5; n++) { + shell("uiautomator", "dump", "/sdcard/moose-route-smoke.xml"); + const view = adb("exec-out", "cat", "/sdcard/moose-route-smoke.xml"); + if (new RegExp(`probe=${id}(?![0-9])`).test(view)) return true; + await delay(500); + } + return false; +} +launch(source); +if (!(await shows(source))) + throw new Error( + "Source controlled fixture not visible; refusing to kill the app", + ); +if ( + shell("dumpsys", "activity", "services", app).includes( + "MooseExecutionService", + ) +) + throw new Error("An execution service is active; refusing to kill the app"); +shell("input", "keyevent", "KEYCODE_HOME"); +await delay(1000); +shell("am", "kill", app); +await delay(500); +let running = false; +try { + running = !!shell("pidof", app).trim(); +} catch {} +if (running) + throw new Error("Process is still running; cold launch was not established"); +launch(target); +if (!(await shows(target))) + throw new Error( + `Cold intent did not select controlled conversation ${target}`, + ); +if ( + shell("dumpsys", "activity", "services", app).includes( + "MooseExecutionService", + ) +) + throw new Error("Reading the saved route unexpectedly started execution"); +console.log( + `PASS: standalone cold route ${source} → ${target}, with no execution service`, +); diff --git a/src/dev/BackgroundSmoke.tsx b/src/dev/BackgroundSmoke.tsx index 3ea89aa..996e063 100644 --- a/src/dev/BackgroundSmoke.tsx +++ b/src/dev/BackgroundSmoke.tsx @@ -29,6 +29,7 @@ import { responseEvents, sseResponse } from "./controlled-sse"; let runtime: Promise | undefined; let requests = 0; +const attempts = new Map(); function getRuntime() { return (runtime ??= (async () => { const db = await openDatabaseAsync("background-smoke.sqlite"); @@ -36,6 +37,20 @@ function getRuntime() { async () => "controlled-fixture-only", (async (_url, init) => { requests++; + const bodyText = String(init?.body); + const probe = /probe=(\d+)/.exec(bodyText)?.[1] ?? "unknown"; + const attempt = (attempts.get(probe) ?? 0) + 1; + attempts.set(probe, attempt); + if ( + bodyText.includes("mode=failure") || + (bodyText.includes("mode=retry") && attempt === 1) + ) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + const retry = bodyText.includes("mode=retry"); + return sseResponse( + `data: ${JSON.stringify({ type: "response.failed", response: { id: "controlled", error: { code: retry ? "server_error" : "invalid_request_error", message: retry ? "Network error" : "Invalid request access_token=fixture-secret" } } })}\n\n`, + ); + } if (String(init?.body).includes("network=1")) { console.log( "MooseBackgroundSmoke network request", @@ -102,7 +117,7 @@ function getRuntime() { {}, acquireBackground, ); - configureBackground((cause) => engine.stopAll(cause)); + configureBackground((cause) => engine.stopAll(cause), engine); await cancelInterruptedBackground((cause) => engine.stopAll(cause)); await engine.resume(); return engine; @@ -131,7 +146,7 @@ export function BackgroundSmokeApp() { }) .catch((e) => setStatus(String(e))); }, []); - async function start(delay = 15000, network = false) { + async function start(delay = 15000, network = false, mode = "success") { try { setStatus("Starting"); const engine = await getRuntime(); @@ -143,7 +158,7 @@ export function BackgroundSmokeApp() { }); const submission = await engine.submit( chat.id, - `Controlled file tool delay=${delay} network=${network ? 1 : 0} probe=${chat.id}`, + `Controlled file tool delay=${delay} network=${network ? 1 : 0} mode=${mode} probe=${chat.id}`, `native-${Date.now()}`, ); setStatus("Running: press Home now"); @@ -179,6 +194,21 @@ export function BackgroundSmokeApp() { {status}