feat(remote-connect): support targeted session rollback from remote control - #2798
Closed
BingCHuanJ wants to merge 3485 commits into
Closed
BingCHuanJ wants to merge 3485 commits into
BingCHuanJ wants to merge 3485 commits into
Conversation
`WorkspaceStdio` documents that the three IO streams are what lease the process, and that `control` and `completion` do not keep it alive by themselves. Both channel loops contradicted that: `control_rx.recv()` returning `None` — which is just the last `WorkspaceProcessControl` sender going out of scope — was matched together with `Kill`, so a caller that drives the process purely over stdio killed it the moment it dropped the handle it had no use for. Remote ACP is exactly that caller. The agent was SIGKILLed microseconds after exec, and the only trace left was a broken pipe on the first `initialize` — no stderr, because it never got to write any. Resolve the arm only on real signals, and cover both loops with a test that dropping the handle lets the process run to its own exit status.
An agent that dies mid-session leaves behind the same broken pipe as one that never started. The exit code says which.
A client that exits before answering `initialize` produced one sentence — "exited before initialization completed" — which tells the user only what they already know. Its stderr held the whole explanation and went to a log file, or, for a local agent, to a terminal a packaged app does not have. Read both transports' stderr into the log line by line, keep the tail, and quote it in the error the user is shown. Waiting for EOF first is what makes the quote complete: the pipes closing is the signal that the agent is gone, and its last lines are still in flight then. Also ask the remote host for its Node version in the probe round trip we already make, and refuse the launch when it is below 20.12 — the release that added `util.parseEnv`, which the harness imports on its first line. dsh declares no `engines`, so npm installs it onto Node 18 without a word and the failure surfaces from deep inside the launcher.
Allow permission changes made during an active turn to affect the next model round while keeping the current round stable. - Keep active-turn overrides mutable and process-local - Clear temporary overrides when the owning turn ends - Persist session-scoped selections and clear active overrides - Add a dedicated active-turn permission command - Distinguish next-message and current-turn scopes in the UI - Preserve compatibility with older session permission requests
DeepSeek Harness's PTC preset answers a whole step with one `run_code` call whose argument is a TypeScript program. The bridge classified it by kind alone, so it landed on the terminal card, which reads `command` — a field the call does not have — and drew an empty card. Give it its own identity and card: `run_code` (and anything shaped like it: Execute kind, a `code` argument, no `command`) becomes `RunCode`, rendered as the program plus what it printed. A shell call that happens to carry a `code` argument is still Bash. While reading the result path: ACP results carry their text in content blocks, but the terminal card reads `output`/`stdout`, so every ACP Bash card showed the command with nothing underneath it. Lift that text into `output` for both cards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An ACP session came back empty after a restart. Its turns were never written, and three layers each had a reason. The projection is the only writer of these turns, but it had no storage slot to write into. That slot arrives with the backend's `DialogTurnStarted`, and no such event exists for a turn an external agent runs — nothing in the local runtime starts it. Every save was therefore deferred, forever. Allocate the slot in the projection instead, from the turns and catalog it already holds, and decline to guess when the session has persisted turns none of which are projected yet, where a guess would overwrite history. The backend then refused the save it did receive. `save_persisted_dialog_turn` validates a turn against the runtime's history branch for that session, and an externally driven session has none — the runtime neither starts nor completes those turns — so a first turn failed with OutcomeUnknown. Read the session's `provider` metadata and, when an external agent owns it, persist straight through. A runtime-owned session still needs its branch, which the new test pins from both sides. Finally, the desktop path loaded every session into the session manager before saving, which for an ACP session restored nothing and rewrote its persisted mode to a local fallback. Skip the load for the same reason: there is no runtime state to restore. The `provider` key and its `acp` value move into core-types, so the ACP client that writes them and the runtime that reads them back share one definition rather than two string literals that have to agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closing a dsh session and clicking it again gave a blank one: the bridge never advertised ACP's `loadSession`, so BitFun had only `session/new` to fall back on. Every one of a user's stored sessions carries `acpResumeStrategy: "new"` for that reason. The reopened conversation lost its history and its context, and its mode picker unlocked and reverted to the roster default — a session that has already spoken must not be able to change the composition its transcript was written under. Implement `session/load`. A live session replays from memory; a cold one resumes out of the harness's own persistence, which is the case that matters, since a client restart is exactly when nothing is in memory. The archive is read through `inspect`, not a listing: a session disposed a moment ago is still draining, and `list` does not wait for it while `inspect` does — reopening the session you just closed is the first thing a user does. The mode comes back from the session's own log rather than the roster, so a conversation started under a preset reopens under it however the default has moved, and `presetOptions` locks the picker for a session whose conversation has started. A session is refused when it was never stored, or when it belongs to another directory — answering the latter would hand back a session whose sandbox boundary points somewhere else. `scripts/smoke.mjs --load <id>` drives the path against a real installation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(acp): ship an ACP bridge for DeepSeek Harness
chore: bump version to 0.2.18
The 0.2.18 Desktop Package run failed in `Package (windows-x64)` with `spawnSync npm ENOENT` out of `build-profile.mjs`, taking the whole `frontend:build-all` down with it. Three separate Windows assumptions: - `npm` is a `.cmd` shim there, and Node has refused to spawn one without a shell since CVE-2024-27980. A shell then re-splits every argument, so passing an absolute `--pack-destination` would break on its first space. Both `npm pack` and `tar` now run *in* the staging directory, which leaves their arguments as bare package names and one filename — no quoting to get wrong, and no drive letter reaching `tar -f`, which GNU tar would read as a remote host. - `copyTree`'s filter derived a basename by slicing on '/', which on a '\'-separated path yields the whole path and therefore matched nothing. A vendored tree would have dragged `node_modules` along. - `hashTree` recorded native separators, so the same sources produced a different content stamp per build host. Digests are unchanged on Unix (verified byte-for-byte against the previous script). `prepare:dsh-profile` runs only inside `frontend:build-all`, which no CI job invokes, so its first Windows execution ever was a release build. Add a small `windows-latest` job that runs the packaging and asserts the profile is complete, stamped, and carries nothing it must not ship. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…endor fix(dsh): package the bridge profile on Windows
Preserve YAML and TOML frontmatter through Markdown and TipTap round trips without exposing structural delimiters for editing. - Render labeled frontmatter metadata in preview mode - Support protected frontmatter editing in IR mode - Preserve delimiters, line endings, comments, and body spacing - Show unsafe syntax warnings only when IR requires fallback - Add focused parser, preview, and editor regression tests
Add the private AgentClient, Session, and Query vertical slice over the existing Agent Runtime owner. Consolidate bounded JSON, JSON-RPC, and WebSocket mechanics in the cross-platform transport foundation while keeping IPC framing and product protocol policy with their owners. Generate strict runtime validators from the Rust wire contract and preserve bounded lifecycle and process-tree cleanup.
cargo check and desktop:dev no longer require packages/dsh-acp/dist-profile. Official packaging still builds the profile and injects it as a Tauri resource.
…pile-resource fix(desktop): keep the DeepSeek bridge off the compile path
Keep release-sync cron on the in-repo script and document the host export/import path so a new server does not need a detached AutoUpdate copy.
docs(deploy): restore the OpenBitFun origin from the BitFun checkout
Remove automatic snap-back when the user scrolls into the reserved blank below the conversation tail. - Preserve explicit session, rollback, navigation, and tail alignment - Keep tail-follow recovery when output catches up with the reader - Update viewport ownership tests and FlowChat behavior documentation
- keep the file explorer on a single virtualized tree renderer - prevent scrollbar appearance from shifting tree content - add regression coverage for stable renderer and scrollbar behavior
Switching devices used to move the whole client onto the target: the UI swapped, and the device you left stopped being usable. So an account with several devices could still only run one task at a time. Split the two concepts that were fused together. An *attachment* is a live control link to a peer and is what keeps its agent running; the *rendered surface* is the single device this window draws. Attachments now survive UI switches, so dispatching a turn on B, switching back to A, and dispatching another turn there leaves both running. Three things had to change for that to hold: - A surface switch no longer mutates the device being left. resetProductSurface() runs before the transport swap, so its terminal_shutdown_all / lsp_close_workspace calls landed on the outgoing device and killed the PTYs and language servers an agent turn was still using. It is frontend-only now. - Product events are routed by source device. The controller re-emits peer DeviceEvents under their original event name, so background attachments put several agent streams on one bus. Re-emitted payloads carry __bitfunSourceDeviceId, and deviceSurfaceRouting delivers a surface-scoped event only when its producing device is the rendered one. - Snapshot reconciliation is no longer Peer-only. A turn left running on the local device produces events that routing drops while another device is rendered, and the relay has no ACK/replay, so returning to it needs the same repair the peer surface already had. The sidebar row becomes a device switcher: always present once signed in, listing this machine plus every online device with live running indicators, a count of devices working elsewhere, and an explicit per-device disconnect that stays distinct from simply looking somewhere else.
The appearance contract audit requires a compound styled owner to expose at least four distinct parts; the switcher declared three, so themes could not address its label, status dots, or disconnect action independently. Tag the remaining styled nodes and register them, plus the busy/offline states the status dot already rendered through class names only.
Switching devices while a local session was working reported "Session lost after adding dialog turn" and the message never ran. `startTurn` adds its projection turn, then awaits the state-machine transition, an optional worktree bind, and a model-selection sync before reading the session back. A surface switch clears every projection synchronously, so a submission caught in that window resumed against an empty store and threw. The throw lands before `start_dialog_turn`, which is the real damage: the turn had reached no host, so the user's message was gone rather than merely rendered somewhere else. The previous change kept the *backend* running across a switch but left frontend work in flight over the teardown. Close the window, then survive losing it anyway: - `resetProductSurface` waits for in-flight submissions to hand their turn to a host before clearing the surface, bounded so a wedged submission cannot make the switch feel stuck. - `sendMessage` captures the store's surface generation and compares it when a submission fails. A change means the switch caused the failure, not the turn: skip the error toast and the error transition, and re-queue the message when no host accepted it. Pending queues are keyed by session and survive a switch, so returning to that device drains it. `TurnTracker` gains `hostAcceptedTurn` so the recovery can tell "never submitted" from "already running elsewhere" instead of guessing.
Switching away from a working local session and back left the prompt on screen with the whole response, its progress, and its result gone. Active-session reconciliation has a wholesale replace path that skips the forward-progress comparator, so a settled turn can adopt the host's authoritative copy. Two things make that unsafe. A turn keeps its identity and user message independently of its rounds, so a windowed or not-yet-checkpointed snapshot can name the turn while carrying none of its work. And a projection rebuilt by a surface switch has no state machines, so every turn reads as idle and every snapshot qualifies for replacement. Reconciling then overwrote a fully hydrated turn with an empty one — exactly the screen the report showed. Gate the replace on `snapshotDropsProjectedTurnContent`: a snapshot may correct a turn, never drop rounds, streams, or tool calls the projection already shows. Forward progress and genuine host copies still replace as before. Refusing a snapshot must not also cost the re-attach, since the same rebuilt surface is what needs one. When a snapshot changes nothing but the host reports an executing turn and the local machine is idle, align the state machine anyway. While a turn really is streaming the machine is already processing, so this cannot churn it on every tick. Found by reproducing the sequence against the real store rather than by inspection: the projection goes rounds=1 -> rounds=0 across one reconcile. Both guards are covered by tests that fail without them.
Keep the current scene visible while a lazy target is loading, then switch atomically once the target is ready. Apply a subtle entrance fade only to the incoming scene and add regression coverage for session and agent scene navigation.
* fix(chat): make remote file mentions reliable * fix(chat): tolerate sessions without config metadata * fix(chat): register mention picker error state
* refactor(peer): add the device surface identity and epoch contract Foundation for the multi-device rework: a DeviceSurfaceId that names which device a piece of state belongs to, a monotonic activation epoch with an AbortSignal, a typed SurfaceChangedError for unwinding stale work, and the key-scoping helper every per-device cache must use. Nothing consumes it yet; the layers land on top of this contract. * refactor(peer): isolate device surface state and switching
The trend chart's cache hit-rate polyline dropped null buckets and joined the remaining points into one polyline, so an idle stretch (all token counters at zero, no cache telemetry) was visually bridged by a high dashed line connecting the buckets on either side - the chart implied a ~97% hit rate over hours where the tooltip correctly showed no data. - split the hit-rate series into contiguous segments that stop at buckets without telemetry; an isolated point renders as a dot - add hover marker dots for every series so small values stay visible on a zero-baseline token axis (e.g. 276K next to a 20M peak reads as flat zero without an anchor) - format the tooltip hit rate with formatHitRate (two-decimal truncate) instead of Math.round for consistency with the summary cards
Splitting the hit-rate series at buckets without telemetry left isolated dots and blank stretches, which read as a broken chart. Idle buckets already draw every token series at zero, so plot the hit rate at 0% there too and keep one continuous dashed line; the tooltip mirrors the line with 0.00% instead of an en dash.
Keep provider-reported cache-write tokens in the backend records, aggregates, persisted statistics, and API response while removing the noisy series from the current chart UI. Render synthesized idle buckets at 0% for continuity, but keep active buckets without cache telemetry as gaps and show an unavailable tooltip value instead of claiming a measured 0% hit rate. Add focused coverage for the hidden series, segmented trend, and tooltip semantics.
Upgrade the transitive h2 dependency from 0.4.15 to the latest patched 0.4.x release. This closes RUSTSEC-2026-0258, which allows unbounded empty DATA frames to cause memory exhaustion or a panic, and includes follow-up fixes that avoid rejecting legitimate small-frame traffic. Only the locked version and checksum change; the dependency graph and application sources are unchanged. Test: cargo check --locked -p bitfun-core; cargo tree --locked -i h2 --depth 1 AI: lightly tested
Co-authored-by: BitFun <318544290+bitfun-ai@users.noreply.github.com>
Prevent idle cleanup from evicting durable sessions while a turn is still processing, and recheck the state immediately before removal. Add a synchronous per-message send-now guard so rapid clicks cannot issue duplicate steering requests. Cover both cleanup races and UI re-entry with regression tests.
Prevent automatic compaction from dropping task state when the latest successful TodoWrite call falls outside the exact recent suffix. - Scope retained TodoWrite snapshots to the active dialog turn - Ignore failed writes and avoid duplicating state already in the tail - Preserve explicit clears and same-turn recompression checkpoints - Render authoritative task state inside a dedicated <todo> block
DeepReview remediation changes a manager-owned session binding before the turn is admitted. The admission snapshot must track the effective route owner and all execution-affecting session bindings so stale configuration cannot start a turn.\n\nSynchronize the turn-local snapshot after the ReviewFixer binding update and reject concurrent model, route, context-window, and workspace binding changes. Add regression coverage for the successful remediation path and admission races.
Co-authored-by: BitFun <318544290+bitfun-ai@users.noreply.github.com>
feat(agent): bundle commit-push-pr skill
…binding fix(session): keep turn admission bindings consistent
BTW and review sessions render outside the main flow-chat container, so a direct permission request could block the child runtime without exposing any way to answer it in the side panel. Render the shared permission mailbox in BtwSessionPanel and separate broad transcript routing from actionable ownership. Delegated subagent requests remain owned by the parent Task surface, while direct review-child requests stay with the child surface. This avoids duplicate permission panels without inheriting the parent's permission mode. Add routing and hook regression coverage for direct and delegated requests, including the one-owner active batch behavior.
…n-panel-routing fix(flow-chat): route permission requests to a single owner
Remove agent-selection instructions from the general-purpose system prompt because they are meant for the caller, not the running agent.
Previously, POSIX-prefixed Windows drive paths (e.g. /C:/foo/main.py) could resolve as drive-relative paths and write files under the process working directory. Missing-marker fallbacks also led agents to resend payloads unnecessarily. Update Write handling to: - Normalize malformed Windows drive paths while preserving remote POSIX path semantics - Report corrected paths and provide platform-specific examples - Direct agents to move preserved fallback files instead of resubmitting content - Cover path normalization and fallback guidance with focused tests
Expose stable MCP invocation parameters in a nested details section so users can inspect requests without crowding the default card view. - Keep parameter details collapsed by default and reset them when the parent card closes - Allow failed and result-less calls to expose available input - Auto-expand each MCP App once while preserving later user collapse - Add focused coverage for input parsing and expansion behavior
Keep the WriteStdin card display limited to the provided chars when append_enter is enabled, while preserving its execution semantics. Add regression coverage for the displayed and copied input text.
Preserve repository ownership failures as a stable error code across review platform commands and reuse the shared trust classifier. Translate that code into localized, actionable copy in the review panel and route user-initiated retries through the existing trust recovery flow. Add backend and frontend coverage for trust classification and localized error handling.
Translate dialog-turn start failures in the Review action bar while preserving specific backend reasons. Allow multiline error details to wrap without truncation and cover formatting, localization, component wiring, and layout behavior.
Resolve explicit file and directory scopes against the active local or remote workspace before starting Review. Return localized missing-target feedback and cover desktop path routing, target resolution, service handling, and API forwarding with regression tests.
fix(review): localize repository and launch failures
…ontrol The remote control surface could only resend a user message or hide it locally, so a phone had no way to undo a turn: the desktop keeps the retired turns and the files those turns wrote, while the browser just stops rendering the message. Reuse the desktop targeted-rollback transaction instead of inventing a second history model: - Project the owning turn identity onto user `ChatMessage`s (`turn_id` + `turn_index`). Rollback addresses turns, not messages, and `turn_index` carries the same optimistic-concurrency guard the desktop sends so a stale transcript cannot retire the wrong turn. - Add `RemoteCommand::RollbackSessionToTurn` and `RemoteResponse::SessionRolledBack` to the owner contract, routed through the existing session command group. - Implement `RemoteSessionRuntimeHost::rollback_session_to_turn` in core on top of `AgentRuntime::rollback_session_to_turn`, after the same workspace ownership gate `delete_session` uses. Remote workspaces stay rejected, matching the existing rollback restriction. - Mobile web gains "edit & resend" and "roll back to here" on user messages. Editing is a rollback followed by a normal send, which is how the desktop reruns an edited user message. The transcript is not truncated client-side: the host marks the history projection dirty, so the next poll delivers an authoritative `message_snapshot`. Rolling back returns the retired turn ids, the restored files and the composer text, so the phone can report what changed and offer the original prompt back for editing.
The rollback boundary is inclusive: `resolve_targeted` sets `boundary_turn = target.turn_index`, and both `hidden_turn_count` and `retired_turn_ids` cover `turn_index >= boundary_turn`, so the targeted turn is withdrawn along with everything after it. Its prompt is what comes back as `replacement_prompt`, which this crate surfaces as `composer_text`. The confirmation sheet and the contract doc comments said "every turn after this message", which understated what the action does on a destructive operation. Correct the en-US / zh-CN / zh-TW strings and the doc comments, and say that the message's own text returns to the input box.
… fails Edit-and-resend is two host round trips: retire the target turn, then send the edited text as a new turn. The rollback lands first and cannot be undone from the phone, so a failing second step left the user with a retired turn and no copy of what they had typed — the sheet closed and cleared the draft on every exit path. Hand the draft to the composer when the send is what failed and the chat target is still the one the operation started on, so the text survives a network or model error. A stale target still discards it rather than writing into a different session's composer. Also fix two review findings: - `.chat-msg__rollback-input:focus` referenced `--color-accent`, which the mobile theme never defines; `colorRamp` only emits `-50` through `-600`. Use `--color-accent-500` like the rest of the file. - Extend the workspace-ownership source audit to cover the new rollback host method, matching how `delete_session` is guarded.
… it lands
Two problems in the mobile rollback flow, both about the window between the
host mutation landing and the phone finding out.
`begin_session_maintenance` takes its permit by clearing the pending queue,
cancelling background subagents and cancelling the active turn, so a rollback
requested while the desktop is working silently destroys that work. The
desktop refuses instead: `assertSessionIdleForHistoryMutation` requires an
idle session and an empty queue. Match it — disable both menu entries and the
confirm button while a turn is active, guard the handler, and say why.
The transcript also stayed stale on the failure path. `nudge()` ran only after
the whole operation succeeded, so a rollback that landed followed by a failed
send left the retired turns on screen until the next idle poll, which is ten
seconds out. Nudge as soon as the rollback returns, before the send can fail.
Also:
- `.chat-msg__menu-sheet` had no height bound, so the edit sheet's autofocused
textarea could raise the keyboard and push the confirm button out of an
unscrollable sheet. Add `max-height: 85dvh` and `overflow-y: auto`.
- Edit mode reported `rollbackDone` ("Rolled back") after it had also sent the
new turn. Give it its own string.
Owner
|
Thank you very much for submitting the PR. We are approaching the 1.0.0-beta release, and we plan to review all PRs based on version 1.0.0 next week. |
Owner
|
Thank you for your contribution. The If your changes are still needed, please reapply them on a fresh branch based on the new We apologize for the disruption and appreciate your understanding. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Remote control gains a real session rollback and "edit & resend" on user
messages, reusing the desktop's targeted-rollback transaction rather than adding
a second history model for remote clients.
Type and Areas
Type: Feature
Areas: Rust core (
bitfun-core,bitfun-services-integrations), mobile webMotivation / Impact
Remote control today offers only resend and delete. Delete is local-only: the
bubble disappears from the browser while the desktop keeps the turn and every
file the later turns wrote. Both actions look like rollback, but neither performs
one, so a phone cannot undo a turn.
After this change a user can roll the session back to any of their own messages —
withdrawing that message and everything after it, and restoring the files those
turns changed — or edit that message and rerun it. The boundary is inclusive,
matching the desktop:
resolve_targetedsetsboundary_turn = target.turn_indexand retires
turn_index >= boundary_turn, so the target turn's own prompt iswhat comes back for editing.
Verification
AI-assisted change. Testing level: tested at the unit/contract level; not run
end-to-end against a paired phone.
New tests cover the wire shape of the command and response, routing into the
session command group, target and guard forwarding, rejection of an empty target
turn, the turn identity carried on user messages, and the workspace-ownership
gate on the new host method.
Not verified locally:
cargo clippy— the local rustup mirror carries no clippy component for thistoolchain, so CI is the first signal.
pnpm run check:core-boundaries— its cargo stage needs whole-workspacecargo metadataincluding the Tauri graph, which did not fit on this machine.The rules relevant here hold by construction:
RemoteCommand/RemoteResponsestay defined in
remote_connect.rs, and the required handler-regression testnames are intact.
appear once the host sends
turn_id, so both need a desktop built from thisbranch. Happy to add captures if you would rather see them before review.
Reviewer Notes
Turn identity. Rollback addresses turns, but the remote transcript carried
only message ids.
ChatMessagenow also carriesturn_idandturn_index, onuser messages only, since a rollback boundary must be a user turn.
turn_indexis forwarded as
expected_storage_turn_index, so a transcript that movedunderneath the client fails instead of retiring a different turn.
Protocol. One new command (
RemoteCommand::RollbackSessionToTurn) and one newresponse (
RemoteResponse::SessionRolledBack), both in the owner contract inremote_connect.rs, routed through the existing session command group.Host side.
RemoteSessionRuntimeHost::rollback_session_to_turnreturns acrate-local outcome type, so
services-integrationskeeps describing the wireshape without depending on runtime-port types. The core impl passes the same
workspace-ownership gate
delete_sessionuses and rejects remote SSH bindings.Session-id validation, the mutation permit, turn-index and catalog-revision
optimistic concurrency, and "target must be a user turn" all come from the
existing transaction unchanged.
Client side. Editing is a rollback followed by an ordinary
send_message,mirroring the desktop. The transcript is deliberately not truncated locally: the
host invalidates its history projection and the next poll delivers an
authoritative
message_snapshot, so the client nudges the poller as soon as therollback returns. Confirmation uses an in-app sheet rather than
window.confirm,and new strings go through i18n in en-US / zh-CN / zh-TW.
Idle-only, like the desktop.
begin_session_maintenancetakes its permit byclearing the pending queue and cancelling the active turn plus its background
subagents, so a mid-task rollback would destroy work the phone cannot see. The
desktop refuses instead (
assertSessionIdleForHistoryMutation), so the mobileentry points and the confirm button are disabled while a turn is active.
Known limitation. The edit sheet inherits the original message's image
attachments (matching
handleResendMessage) but shows no thumbnails and offersno way to drop one. Left for a follow-up.
Alternatives considered: putting turn identity in
ChatMessage.metadata(smallerdiff, but this identity authorizes a destructive operation, so typed fields keep
the contract test meaningful); a dedicated
edit_and_reruncommand (editing isrollback plus a send, so a second command would duplicate admission control);
truncating the transcript client-side (
message_snapshotalready exists forthis); changing the existing delete action (left alone to keep this PR focused,
so it remains a local hide).
Compatibility:
turn_id/turn_indexare optional and skipped when absent, soan older client ignores them, and a host without this command answers with the
existing error shape.
Checklist