Quick Start
Answer an approval
decide resolves only once the engine has recorded the decision, so the UI never shows “Allowed” before the request landed. The moment you tap, the row’s buttons disable themselves, so a double tap on a shaky connection cannot send the same decision twice.Cancel a live run
runId, delivered on the start event. cancelled is announced, never inferred from a stream that ends.Approval Choices
Anapproval_request carries approvalId, callId, name, and args.
approvalId. callId only says which tool row to attach the prompt to.
approvalId and runId are opaque strings, so remote-http passes them through encodeURIComponent before splicing them into the approve/cancel paths. An id containing /, #, or ? therefore still reaches the engine as one path segment — before #4589 the id was pasted verbatim, so ap/1?x=2#f posted the decision to a different path, the engine never saw it, and the run stayed blocked until its timeout while the UI reported the decision as sent.decide call itself throws — a socket closed mid-send — the row renders as failed and carries the reason (e.g. "socket closed"). It is never quietly acknowledged as sent, because the engine may not have received the decision at all.Approvals are per turn
The approval table is cleared at the start of every turn and again whensetChat() switches conversations, so a new prompt is never confused with an answered one.
An engine may number approvalId per run — the protocol only guarantees uniqueness within a single run. Reusing an id across turns once shadowed the new request behind the previous turn’s answered entry: turn 1’s prompt rendered as sent/allow with dead buttons, still carrying turn 0’s args. A user could be shown rm -rf / as already-allowed because an earlier ls reused the id.
turn reset, send() publishes the stale turn before the new run starts, so New chat cleared the screen only until the user’s next message — and the previous chat’s pending approval reappeared with live buttons. A prompt to run rm -rf /, abandoned by starting a new chat, came back with live buttons on the next Send, and Allow posted the decision to the engine.
The within-turn rule is unchanged: the same approvalId twice inside one run is still the engine repeating itself — one prompt, not two.
Before the turn reset, New chat cleared only the screen — the stale approval reappeared on the next Send.
Refused decisions stay refused
A decision issending until the engine confirms it, and only then sent. If the engine refuses the decision, acknowledge never promotes it.
rm -rf / back into a live prompt. Pinned by "a decision the engine already CONFIRMED cannot be un-confirmed by a late failure" in core/src/run/approvals.test.ts.
buildTranscript(turn, approvals) — because a row built from the turn alone would stay pending forever: the user would tap Deny and the card would never acknowledge it. Pinned by "an approval row shows the decision after the user answers it" in app/src/main.test.ts, which drives an approval_request end-to-end, clicks Deny, and asserts data-state moves off pending.The Two-Approval Case
When two approvals are outstanding, the prompts arrive in the opposite order to their tool rows. Routing by position authorises the wrong command.Cancellation & Queued Prompts
runId arrives on start and is the only handle cancel accepts.
stop returns true when it cancelled a live run and false when there was nothing to cancel — including when the engine’s cancel POST returns any non-200 status. Stop cancels the network request too: the controller aborts the same signal it handed to fetch, so a request already in flight is dropped rather than left generating tokens after you tapped the button.
Stop cancels the run, not the chat. The controller passes the engine the runId it was handed on start, never the chatId. A cancel handed the wrong id returns false, the model keeps generating and billing, and the Stop button silently does nothing. Pinned by "Stop cancels the RUN, not the chat" in core/src/run/controller.test.ts, which asserts cancel receives the same id run was given and that it is not the chatId.What the controller hands the engine
Every turn the controller assembles aRunRequest and hands it to the engine. Three of its fields are guarantees the user feels directly.
- Attachments make the trip. A photo the user attaches to Send arrives on the engine’s
RunRequest.attachments, not dropped. Pinned by"an attachment the user picked reaches the engine"incore/src/run/controller.test.ts. - Tools are requested, not silently off. The controller sets
tools: trueon theRunRequest, so a turn that needs a tool can call one. Pinned by"tools are requested, not silently disabled". - No ticker leaks past a finished turn. The pacing interval that ticks during a run is stopped in
finally, so twenty messages do not leak twenty 16 ms timers on the phone. Pinned by"a finished turn leaves no timer running".
for await iterator, and the engine’s finally calls await reader.cancel(), so the model stops generating and the socket is released. Before #4579, only the request abort was covered — the response stream leaked and the model kept billing after you left.
stop() captures the run when tapped. It no longer re-reads live after awaiting the engine, so tapping Stop just as the last token lands cannot throw a TypeError that blanks the whole screen when the queue is empty, nor cancel the queued follow-up instead of the finished run.
If you switch apps or lock the phone mid-answer, the run is cancelled — you won’t come back to a burning credit meter.
AbortSignal is already down, the engine aborts before subscribing — no deltas stream, and nothing is persisted for the aborted run. Before #4581, the run streamed to completion and persisted an answer that had no consumer. The dispose-then-new-run path is the other route into the same already-aborted signal. See Mobile Engines for the engine-side detail.abort_ignored) in the engines conformance suite. See Adapter Conformance.A user-stopped turn reads as cancelled, not transport
The controller branches in the read-loop’s catch on abort.signal.aborted. If the abort was ours — tapping Stop cancels the engine, aborts the reader, and the underlying fetch errors the body stream — the turn ends cancelled with the grey “Stopped” notice. If the stream dies on its own — socket reset, mid-stream 5xx — the turn ends with a transport error and the red “Connection lost” retry.
Stop is idempotent and race-safe
Stop is safe to call more than once, and two overlapping stops share a single cancel. A second tap reportstrue, not a refusal: the controller short-circuits on an already-aborted run instead of asking the engine again. The background → dispose path calls stop() twice by design, so the second call must not read as a failure.
Two stops that overlap — the background handler and the user’s Stop button, say — both receive the same in-flight promise. The engine is asked exactly once, and both callers get the true result. This removes the false “the engine did not accept the stop” notice that once fired for a stop that did happen.
Idempotence never invents a cancellation: stop() still returns false when there was nothing to stop.
A refused stop is retryable
Afalse from the engine leaves the run alive and does not short-circuit the next tap.
When the engine answers cancel(runId) === false, stop() clears run.cancelling and never calls run.abort.abort(). Two things follow: the reader stays attached to a run the engine says is still running, and aborted stays unset, so the next tap reaches the engine again rather than hitting the idempotence guard.
true reaches the user as silence — stopNotice defines an accepted stop as no notification. A second false shows the refusal again. Before this, the idempotence guard aborted the reader even on a refusal, so the retry short-circuited to true without asking the engine, and the run kept generating and billing while the user was told nothing.
When Stop is refused
The app tells you when a stop did not land, instead of going quiet as though it worked. When the engine genuinely refuses a stop —controller.stop() returns false because the run was not live, or the underlying stop call rejects — the user sees a notification carrying the stopRefused string (“The engine did not accept the stop. It may still be running.”). A button that quietly confirms a cancellation that never happened is worse than one that reports it could not.
controller.stop() before it clears the transcript, so starting a fresh conversation cancels any live run. See New chat semantics.end and no usage, yet it stays on screen — which is why its index cannot be computed client-side.Best Practices
Never derive an approval from a row position
Never derive an approval from a row position
Cancel by runId only
Cancel by runId only
cancel returns false when the run was not live, and also when the engine’s cancel POST returns any non-200 status — a Stop button that confirms a cancellation that never happened is worse than one that reports it could not. The regression test "a decision or cancel the engine did NOT accept is reported as refused" iterates [202, 400, 401, 403, 500, 502] and asserts cancel returns false for each.A decision the engine did not accept is false
A decision the engine did not accept is false
decide returns false for a decision the engine did not accept, including a non-200 response body — not only an unknown or already-decided id. The sending → sent promotion in Refused decisions stay refused depends on it: a lying true once promoted the row past sent, so this transport-layer check is the other half of that guarantee. The same test above asserts decide returns false across [202, 400, 401, 403, 500, 502], with "a 200 with an ok body is accepted, so the refusal test is not vacuous" as the companion.A new turn starts a new approval table
A new turn starts a new approval table
approvalId on a later turn is a fresh request, never a repeat of the answered one. Switching chats also clears the pending prompts, so a different conversation cannot inherit the last one’s approvals.An accepted stop is safe to call twice
An accepted stop is safe to call twice
true without re-asking. The guard applies only after the engine confirms.A refused stop is safe to retry
A refused stop is safe to retry
false from the engine does not set aborted, so the next tap reaches the engine again instead of short-circuiting. Retrying a refused stop actually re-asks; a second acceptance is silent, a second refusal shows the notice again.Distinguish cancelled from transport by who aborted the stream
Distinguish cancelled from transport by who aborted the stream
cancelled used to be virtually unreachable. Reading abort.signal.aborted at catch time makes the outcome deterministic: a user-tapped Stop reads as cancelled (grey “Stopped”), and a stream that dies on its own reads as transport (red “Connection lost” with Retry).Stop and flush on background
Stop and flush on background
controller.stop() the moment the app enters the background phase.A pending decision disables the row
A pending decision disables the row
disabled = !actionable. A user hitting Allow twice on a shaky connection cannot send two decisions for the same approval. The three buttons themselves must carry three distinct choices; every layer above the DOM routes a decision by approvalId, and the last hop decides what the decision is — so a bug that stamped "allow" onto every button would go unnoticed. Pinned by "each approval button carries its OWN choice" in app/src/dom.test.ts, which walks the row, asserts exactly three data-choice buttons whose values sort to ["allow", "always", "deny"], and checks that each button’s data-approval-id matches the row’s approval id.An acknowledged decision records the choice the user actually made
An acknowledged decision records the choice the user actually made
acknowledge promotes sending → sent and writes the user’s own choice into the sent state — it is not permitted to substitute a fixed value. An acknowledged Deny is recorded as deny, not allow; the outstanding count, the audit trail, and the row the user reads back all agree with the button the user actually pressed. Pinned by "acknowledging records the choice the user actually made" in core/src/run/approvals.test.ts, which iterates ["allow", "always", "deny"] and asserts the sent.choice on each.Break out of the iterator to release the socket
Break out of the iterator to release the socket
break — or a return, or a thrown exception — out of the for await loop is the cancellation channel; there is no separate close(). The engine’s finally calls await reader.cancel(), so leaving the stream releases the reader and the model stops billing. The regression test "leaving the stream early releases the socket" asserts the underlying ReadableStream’s cancel() fires when the consumer breaks after the first delta. See Stop is idempotent and race-safe.Before #4579, a refused stop or decision read as success
Before #4579, a refused stop or decision read as success
postOk did not check the status it received, so any non-200 — 202, 401, 500, 502 — reported success. As the survival note in #4579 puts it: “Every non-200 — 202, 401, 500, 502 — reports success. The UI announces a stop that never happened and marks an approval sent that the engine never received.” The fix adds if (response.status !== 200) return false;, pinned by "a decision or cancel the engine did NOT accept is reported as refused".Approval and run ids are URL-encoded, not pasted
Approval and run ids are URL-encoded, not pasted
approvalId and runId as opaque, so an engine is free to hand back an id containing /, #, or ?. remote-http URL-encodes both before building the approve or cancel path so the id reaches the engine as a single segment. Pinned by "an approval id with URL-significant characters is encoded, not pasted" and "a cancel with an awkward run id is encoded too" in remote-http/engine.test.ts.
