From f4e285bd9c6ec2a7fb2044901b24d4acdaa58eb3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:08:14 -0800 Subject: [PATCH] refactor(mobile): finish concrete owner and test placement cleanup (#40643) * refactor(mobile): finish concrete owner and test placement cleanup * test(mobile): finish whole-case policy cleanup after review --- config/reliability-gates.jsonc | 6 +- .../mobile-ai-vault-resume-operations.ts | 1 - .../mobile-session-browser-operations.ts | 1 - .../native-chat-file-search-operations.ts | 7 - .../mobile-file-tap-operations.ts | 1 - .../mobile-markdown-tab-operations.ts | 9 - .../mobile-agent-detection-operations.ts | 7 - .../mobile-runtime-repo-operations.ts | 7 - mobile/src/session/floating-workspace.test.ts | 12 +- ...mobile-session-appearance-startup.test.tsx | 77 ----- .../session/mobile-session-status-result.ts | 3 - .../MobileNativeChatView.turn-status.test.ts | 213 -------------- ...mobile-native-chat-page-read-operations.ts | 7 - .../mobile-draft-inputs-scaling.test.ts | 166 ----------- ...mobile-native-chat-file-attachment.test.ts | 21 +- .../mobile-native-chat-image-scope-state.ts | 18 -- .../mobile-native-chat-skill-operations.ts | 7 - .../use-mobile-native-chat-drafts.test.ts | 156 ---------- .../MobileNativeChatBackgroundTasks.test.tsx | 66 ----- ...mobile-native-chat-answer-stepping.test.ts | 56 ---- .../mobile-native-chat-answer-stepping.ts | 13 - ...tive-chat-answered-prompts.test-fixture.ts | 261 ----------------- .../use-mobile-native-chat-answer-send.ts | 6 +- ...ructured-agent-options.chat-return.test.ts | 107 ------- .../mobile-native-chat-diff.test.ts | 24 -- .../mobile-native-chat-merge.test.ts | 49 ---- .../mobile-native-chat-merger-oracle.test.ts | 67 ----- .../MobileNativeChatTurnStatus.test.ts | 147 ---------- ...ile-agent-message-renderer.test-fixture.ts | 6 +- .../mobile-native-chat-stream-frame.test.ts | 62 ++++ .../native-chat-tool-run-words.test.ts | 47 --- ...ative-chat-turn-draw-order-scaling.test.ts | 87 ------ ...mobile-native-chat-terminal-stream.test.ts | 272 +----------------- .../mobile-session-tab-read-operations.ts | 7 - .../mobile-session-tab-write-operations.ts | 2 - .../mobile-session-tab-write-reply-schema.ts | 1 - .../use-missing-worktree-bounce.test.ts | 67 +---- ...le-default-session-view-preference.test.ts | 69 ----- .../use-mobile-session-view-mode.test.ts | 89 ------ .../review/mobile-review-notes-operations.ts | 4 - .../quick-commands/QuickCommandRow.test.ts | 110 ------- .../mobile-quick-command-operations.ts | 9 - .../mobile-quick-command-reply-schema.ts | 1 - .../mobile-session-terminal-operations.ts | 7 - .../rpc/rpc-uninterpreted-reply-schemas.ts | 1 - .../session-worktree-record-operations.ts | 7 - .../tools/native-chat-diff.test.ts | 13 + 47 files changed, 88 insertions(+), 2288 deletions(-) delete mode 100644 mobile/src/session/mobile-session-appearance-startup.test.tsx delete mode 100644 mobile/src/session/mobile-session-status-result.ts delete mode 100644 mobile/src/session/native-chat/MobileNativeChatView.turn-status.test.ts delete mode 100644 mobile/src/session/native-chat/composer/mobile-draft-inputs-scaling.test.ts delete mode 100644 mobile/src/session/native-chat/composer/mobile-native-chat-image-scope-state.ts delete mode 100644 mobile/src/session/native-chat/input/mobile-native-chat-answer-stepping.test.ts delete mode 100644 mobile/src/session/native-chat/input/mobile-native-chat-answer-stepping.ts delete mode 100644 mobile/src/session/native-chat/input/mobile-native-chat-answered-prompts.test-fixture.ts delete mode 100644 mobile/src/session/native-chat/input/use-mobile-structured-agent-options.chat-return.test.ts delete mode 100644 mobile/src/session/native-chat/mobile-native-chat-diff.test.ts delete mode 100644 mobile/src/session/native-chat/mobile-native-chat-merge.test.ts delete mode 100644 mobile/src/session/native-chat/mobile-native-chat-merger-oracle.test.ts delete mode 100644 mobile/src/session/native-chat/transcript/MobileNativeChatTurnStatus.test.ts create mode 100644 mobile/src/session/native-chat/transcript/mobile-native-chat-stream-frame.test.ts delete mode 100644 mobile/src/session/native-chat/transcript/native-chat-tool-run-words.test.ts delete mode 100644 mobile/src/session/native-chat/transcript/native-chat-turn-draw-order-scaling.test.ts delete mode 100644 mobile/src/terminal/quick-commands/QuickCommandRow.test.ts create mode 100644 src/shared/native-chat/tools/native-chat-diff.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index a95de3b23ef..a92046ae786 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -12103,7 +12103,7 @@ "providers": ["remote-runtime", "ssh", "local", "daemon"], "coveredPlatforms": [], "coveredProviders": [], - "coverageNotes": "Audit1400 intentionally deletes or reduces the listed sources. No fresh execution result validates their reduced selectors. Historical coverage account: Local macOS evidence covers runtime-RPC stream budgets, paired-renderer parse/discard credit, and negotiated host-side suppression for hidden paired desktop panes. Deferred credit is shared by local and remote transports, batches ACKs at 192 KiB or 4 ms, grows per-stream windows from 512 KiB to 2 MiB and aggregate windows from 2 MiB to 8 MiB, bounds queued output to 256 KiB per stream, and caps each multiplex connection at 32 active or pending streams for an 8 MiB aggregate pending-output ceiling. Deterministic tests cover replay ordering, stale generations, malformed frames, hidden panes, queue eviction, disposal, send/recovery failure, repeated pending-slot replacement, reconnect, mixed-version pause negotiation, and round-robin fairness. The opt-in benchmark models 1/20/100 ms RTT and 1/4/8 viewers, throughput, window use, completion spread and protocol-frame counts. It measures model scheduler elapsed time separately from actual @xterm/headless parser CPU/retained heap; modeled credit and ACK results do not qualify production credit enforcement. Live Android restore evidence, browser/WebGL parser measurements, and legacy JSON subscribe parity remain required. The audit removed the PTY ACK wrapper suite, which replayed credit arithmetic with mocked IPC. Its isolated wrapper alarms are no longer registered here; generic credit and runtime stream tests do not establish equivalent PTY integration coverage. The audit removes the standalone auto-settle-before-deferred-claim example. Nested-owner and claimed-child completion tests remain; they do not establish that removed timing alarm. The test-value audit removes the bulk-ACK threshold, output-pause capability/paused-input, transformed-metadata disposal, late-parser and renderer-throw examples. The retained captured-parser, malformed-frame and failed-ACK timer checks do not exercise real xterm parsing or server credit enforcement and do not replace the removed alarms. Historical run commands and results remain historical. The test-value audit removes the pure mobile stream-decision suite: pause/resume/rearm/viewport branch inventories no longer have that isolated alarm. The surviving stream-hook tests do not establish equivalent WebView restore, PTY resize, input-lease or subscription delivery coverage. Historical run commands and counts are preserved as historical records; a narrowed selector is not a new passed run. Audit1302: src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts: Keep production RPC dispatcher/terminal framing with emitted binary bytes, UTF-8 reconstruction and client ACK releasing genuinely withheld output; also keep the direct binary-subscribe chunk boundary. Remove shared-budget fairness matrix that sleeps real time and relies on exact synthetic per-stream window scheduling. Lose that eight-stream fairness/call wiring alarm; no equivalent replacement. Reduced selectors require fresh validation; prior full-suite receipts do not validate removed or changed coverage. No equivalent replacement or physical-provider claim. Audit1341 scope1334 removes/narrows mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts. Exact retired scenarios are intentionally lost; surviving assertions are limited to unchanged retained authored cases. No equivalent replacement, physical phone or deployed mixed-host claim. Fresh validation is required for narrowed commands. Audit1391: src/renderer/src/components/terminal-pane/pty-connection-remote-pause-drop-notice.spec.ts reduced. Lose repeated quiet switches avoiding snapshots and legacy host without drop-report capability requesting a snapshot. Retained imported registrar uses fake transport/xterm writes, exercising real gap/restore lifecycle rather than actual terminal rendering. No equivalent replacement coverage is claimed. The existing primary-selector receipt and its claims remain scoped to their unchanged sources; the broad optional command has no new execution result. Audit1400: src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts removed. Lose ORCA_TERMINAL_PERF_BENCH diagnostic report and local xterm parser measurements; this was not an ordinary CI assertion gate. No equivalent replacement coverage is claimed.", + "coverageNotes": "Audit1400 intentionally deletes or reduces the listed sources. No fresh execution result validates their reduced selectors. Historical coverage account: Local macOS evidence covers runtime-RPC stream budgets, paired-renderer parse/discard credit, and negotiated host-side suppression for hidden paired desktop panes. Deferred credit is shared by local and remote transports, batches ACKs at 192 KiB or 4 ms, grows per-stream windows from 512 KiB to 2 MiB and aggregate windows from 2 MiB to 8 MiB, bounds queued output to 256 KiB per stream, and caps each multiplex connection at 32 active or pending streams for an 8 MiB aggregate pending-output ceiling. Deterministic tests cover replay ordering, stale generations, malformed frames, hidden panes, queue eviction, disposal, send/recovery failure, repeated pending-slot replacement, reconnect, mixed-version pause negotiation, and round-robin fairness. The opt-in benchmark models 1/20/100 ms RTT and 1/4/8 viewers, throughput, window use, completion spread and protocol-frame counts. It measures model scheduler elapsed time separately from actual @xterm/headless parser CPU/retained heap; modeled credit and ACK results do not qualify production credit enforcement. Live Android restore evidence, browser/WebGL parser measurements, and legacy JSON subscribe parity remain required. The audit removed the PTY ACK wrapper suite, which replayed credit arithmetic with mocked IPC. Its isolated wrapper alarms are no longer registered here; generic credit and runtime stream tests do not establish equivalent PTY integration coverage. The audit removes the standalone auto-settle-before-deferred-claim example. Nested-owner and claimed-child completion tests remain; they do not establish that removed timing alarm. The test-value audit removes the bulk-ACK threshold, output-pause capability/paused-input, transformed-metadata disposal, late-parser and renderer-throw examples. The retained captured-parser, malformed-frame and failed-ACK timer checks do not exercise real xterm parsing or server credit enforcement and do not replace the removed alarms. Historical run commands and results remain historical. The test-value audit removes the pure mobile stream-decision suite: pause/resume/rearm/viewport branch inventories no longer have that isolated alarm. The surviving stream-hook tests do not establish equivalent WebView restore, PTY resize, input-lease or subscription delivery coverage. Historical run commands and counts are preserved as historical records; a narrowed selector is not a new passed run. Audit1302: src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts: Keep production RPC dispatcher/terminal framing with emitted binary bytes, UTF-8 reconstruction and client ACK releasing genuinely withheld output; also keep the direct binary-subscribe chunk boundary. Remove shared-budget fairness matrix that sleeps real time and relies on exact synthetic per-stream window scheduling. Lose that eight-stream fairness/call wiring alarm; no equivalent replacement. Reduced selectors require fresh validation; prior full-suite receipts do not validate removed or changed coverage. No equivalent replacement or physical-provider claim. Audit1341 scope1334 removes/narrows mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts. Exact retired scenarios are intentionally lost; surviving assertions are limited to unchanged retained authored cases. No equivalent replacement, physical phone or deployed mixed-host claim. Fresh validation is required for narrowed commands. Audit1391: src/renderer/src/components/terminal-pane/pty-connection-remote-pause-drop-notice.spec.ts reduced. Lose repeated quiet switches avoiding snapshots and legacy host without drop-report capability requesting a snapshot. Retained imported registrar uses fake transport/xterm writes, exercising real gap/restore lifecycle rather than actual terminal rendering. No equivalent replacement coverage is claimed. The existing primary-selector receipt and its claims remain scoped to their unchanged sources; the broad optional command has no new execution result. Audit1400: src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts removed. Lose ORCA_TERMINAL_PERF_BENCH diagnostic report and local xterm parser measurements; this was not an ordinary CI assertion gate. No equivalent replacement coverage is claimed. Broader617 Mobile owner follow-up retires six whole work-count/timing registrations from mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts, retaining five functional handoff/readiness/absence/recovery registrations. Prior Audit1400 retained-but-unvalidated and archived inventories describe earlier source and remain historical after this follow-up. No equivalent replacement, fresh execution, timing measurement, physical phone, or deployed mixed-host validation is claimed by this source change. Current premerge review correction for the Broader617 Mobile owner follow-up: four additional whole mock-only/mock-produced-state stream registrations are retired, for ten total cuts and one unchanged retained registration, keeps asking until a tab snapshot replaces the exhausted handle. The preceding five-retained description records the superseded candidate, not current coverage. The retained case checks the real hook-owned recovery-needed result; handoff, readiness and absence-marker mock-delivery alarms are intentionally lost without equivalent replacement. Dead test render counting and unused handoff setup are removed. No new native, physical-phone, timing or deployed-host proof is implied.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", @@ -12160,7 +12160,9 @@ "Audit1400 retained but unvalidated assertion inventory pending a new run of the reduced command:\n[\n {\n \"file\": \"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts\",\n \"assertions\": [\n \"legacy binary output queued during initial snapshot serialization stays bounded\",\n \"stale mobile resize re-stream completions are dropped for legacy binary streams\",\n \"aborted stream signals do not register stale listeners\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-output-batching.test.ts\",\n \"assertions\": [\n \"desktop output bursts coalesce before stream emission\",\n \"large binary output is encoded lazily before the first output frame\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts\",\n \"assertions\": [\n \"requested snapshots fall back smaller when serialized data exceeds the send budget\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts\",\n \"assertions\": [\n \"oversized live output frames are bounded for subscribed binary streams\",\n \"multibyte live output flushes when encoded bytes reach the batch budget\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts\",\n \"assertions\": [\n \"send and recovery serialization failures detach once instead of leaking credit or retrying forever\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts\",\n \"assertions\": [\n \"32 active or pending slots cap aggregate queued output and repeated pending-slot subscribe cancels its older waiter\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts\",\n \"assertions\": [\n \"three negotiated hidden desktop streams emit zero output frames under sustained load while an unpaused stream remains live\",\n \"an older client that omits pause negotiation continues receiving output\"\n ]\n },\n {\n \"file\": \"src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts\",\n \"assertions\": [\n \"a runtime-owned hidden pane pauses output, consumes status/title/theme facts, and keeps input writable\",\n \"reveal resumes before one authoritative snapshot and exact live output with no hidden raw write, loss, or duplication\",\n \"dispose releases pause and unregisters the fact consumer\"\n ]\n },\n {\n \"file\": \"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts\",\n \"assertions\": [\n \"desired pause reapplies after either capability/snapshot ordering and across reconnect\",\n \"reconnect delivers each authoritative snapshot and post-resume live marker exactly once\"\n ]\n },\n {\n \"file\": \"src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts\",\n \"assertions\": [\n \"nested synchronous deliveries restore the outer credit owner\",\n \"every claimed scheduler child must settle before the parent credits\"\n ]\n },\n {\n \"file\": \"src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts\",\n \"assertions\": [\n \"paired renderer ACK waits for the output scheduler captured parse callback before releasing encoded-byte credit\",\n \"unknown stream output is unsubscribed and malformed framing closes the connection; malformed transformed data is discarded and credited before resync\",\n \"an ACK write failure tears down the stream with zero rearmed flush timers under fake time\"\n ]\n },\n {\n \"file\": \"mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts\",\n \"assertions\": [\n \"trades a lease-only stream for output when a chat tab hands off to a terminal tab\",\n \"repairs a lease-only stream once its WebView reports ready\",\n \"stops rearming a handle whose stream never comes back (covered: %s, #10681)\",\n \"does not charge the rearm budget for a subscribe its own gates turned away\",\n \"charges the rearm budget for a subscribe the bridge refused before returning\",\n \"refills the rearm budget when terminal.list reports the handle again (covered: %s, #10681)\",\n \"does not let a dead PTY buy a new budget with its own `subscribed` ack\",\n \"refills the budget for a rearmed stream that outlives the teardown window\",\n \"keeps an absence marker observed before the budget ran out\",\n \"keeps asking until a tab snapshot replaces the exhausted handle\",\n \"resumes a cold-start lease-only stream when WebView readiness arrives late\"\n ]\n }\n]", "Audit1400 historical assertion inventory for deleted/reduced sources; not current validated assertions:\n[\n {\n \"file\": \"src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts\",\n \"assertions\": [\n \"the opt-in benchmark reports modeled RTT throughput, window use, protocol-frame counts and completion spread, with model scheduler elapsed time and separately measured @xterm/headless parser CPU and retained heap\"\n ]\n }\n]", "Audit1400 archived original active assertionRefs verbatim after retirement of all passed receipts:\n[\n {\n \"file\": \"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts\",\n \"assertions\": [\n \"legacy binary output queued during initial snapshot serialization stays bounded\",\n \"stale mobile resize re-stream completions are dropped for legacy binary streams\",\n \"aborted stream signals do not register stale listeners\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-output-batching.test.ts\",\n \"assertions\": [\n \"desktop output bursts coalesce before stream emission\",\n \"large binary output is encoded lazily before the first output frame\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts\",\n \"assertions\": [\n \"requested snapshots fall back smaller when serialized data exceeds the send budget\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts\",\n \"assertions\": [\n \"oversized live output frames are bounded for subscribed binary streams\",\n \"multibyte live output flushes when encoded bytes reach the batch budget\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts\",\n \"assertions\": [\n \"send and recovery serialization failures detach once instead of leaking credit or retrying forever\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts\",\n \"assertions\": [\n \"32 active or pending slots cap aggregate queued output and repeated pending-slot subscribe cancels its older waiter\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts\",\n \"assertions\": [\n \"three negotiated hidden desktop streams emit zero output frames under sustained load while an unpaused stream remains live\",\n \"an older client that omits pause negotiation continues receiving output\"\n ]\n },\n {\n \"file\": \"src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts\",\n \"assertions\": [\n \"a runtime-owned hidden pane pauses output, consumes status/title/theme facts, and keeps input writable\",\n \"reveal resumes before one authoritative snapshot and exact live output with no hidden raw write, loss, or duplication\",\n \"dispose releases pause and unregisters the fact consumer\"\n ]\n },\n {\n \"file\": \"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts\",\n \"assertions\": [\n \"desired pause reapplies after either capability/snapshot ordering and across reconnect\",\n \"reconnect delivers each authoritative snapshot and post-resume live marker exactly once\"\n ]\n },\n {\n \"file\": \"src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts\",\n \"assertions\": [\n \"nested synchronous deliveries restore the outer credit owner\",\n \"every claimed scheduler child must settle before the parent credits\"\n ]\n },\n {\n \"file\": \"src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts\",\n \"assertions\": [\n \"paired renderer ACK waits for the output scheduler captured parse callback before releasing encoded-byte credit\",\n \"unknown stream output is unsubscribed and malformed framing closes the connection; malformed transformed data is discarded and credited before resync\",\n \"an ACK write failure tears down the stream with zero rearmed flush timers under fake time\"\n ]\n },\n {\n \"file\": \"src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts\",\n \"assertions\": [\n \"the opt-in benchmark reports modeled RTT throughput, window use, protocol-frame counts and completion spread, with model scheduler elapsed time and separately measured @xterm/headless parser CPU and retained heap\"\n ]\n },\n {\n \"file\": \"mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts\",\n \"assertions\": [\n \"trades a lease-only stream for output when a chat tab hands off to a terminal tab\",\n \"repairs a lease-only stream once its WebView reports ready\",\n \"stops rearming a handle whose stream never comes back (covered: %s, #10681)\",\n \"does not charge the rearm budget for a subscribe its own gates turned away\",\n \"charges the rearm budget for a subscribe the bridge refused before returning\",\n \"refills the rearm budget when terminal.list reports the handle again (covered: %s, #10681)\",\n \"does not let a dead PTY buy a new budget with its own `subscribed` ack\",\n \"refills the budget for a rearmed stream that outlives the teardown window\",\n \"keeps an absence marker observed before the budget ran out\",\n \"keeps asking until a tab snapshot replaces the exhausted handle\",\n \"resumes a cold-start lease-only stream when WebView readiness arrives late\"\n ]\n }\n ]", - "Audit1400 archived active selectors after retirement of all passed receipts: {\"commands\":[\"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts --maxWorkers=1\",\"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts\",\"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts --maxWorkers=1\",\"ORCA_TERMINAL_PERF_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts --disableConsoleIntercept src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts\",\"pnpm --dir mobile exec vitest run --root .. mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts\"],\"testFiles\":[\"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts\",\"src/main/runtime/rpc/terminal-output-batching.test.ts\",\"src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts\",\"src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts\",\"src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts\",\"src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts\",\"src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts\",\"src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts\",\"src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts\",\"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts\",\"src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts\",\"src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts\",\"src/renderer/src/runtime/runtime-terminal-stream.test.ts\",\"mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts\"]}" + "Audit1400 archived active selectors after retirement of all passed receipts: {\"commands\":[\"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts --maxWorkers=1\",\"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts\",\"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts --maxWorkers=1\",\"ORCA_TERMINAL_PERF_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts --disableConsoleIntercept src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts\",\"pnpm --dir mobile exec vitest run --root .. mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts\"],\"testFiles\":[\"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts\",\"src/main/runtime/rpc/terminal-output-batching.test.ts\",\"src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts\",\"src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts\",\"src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts\",\"src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts\",\"src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts\",\"src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts\",\"src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts\",\"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts\",\"src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts\",\"src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts\",\"src/renderer/src/runtime/runtime-terminal-stream.test.ts\",\"mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts\"]}", + "Broader617 Mobile owner follow-up retires six whole work-count/timing registrations from mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts, retaining five functional handoff/readiness/absence/recovery registrations. Prior Audit1400 retained-but-unvalidated and archived inventories describe earlier source and remain historical after this follow-up. No equivalent replacement, fresh execution, timing measurement, physical phone, or deployed mixed-host validation is claimed by this source change.", + "Current premerge review correction for the Broader617 Mobile owner follow-up: four additional whole mock-only/mock-produced-state stream registrations are retired, for ten total cuts and one unchanged retained registration, keeps asking until a tab snapshot replaces the exhausted handle. The preceding five-retained description records the superseded candidate, not current coverage. The retained case checks the real hook-owned recovery-needed result; handoff, readiness and absence-marker mock-delivery alarms are intentionally lost without equivalent replacement. Dead test render counting and unused handoff setup are removed. No new native, physical-phone, timing or deployed-host proof is implied." ], "demotionRule": "Cannot promote while a supported stream path has uncapped snapshot or live-output buffering." }, diff --git a/mobile/src/agent-history/mobile-ai-vault-resume-operations.ts b/mobile/src/agent-history/mobile-ai-vault-resume-operations.ts index e3a0dbc0ef2..879c6fde5e3 100644 --- a/mobile/src/agent-history/mobile-ai-vault-resume-operations.ts +++ b/mobile/src/agent-history/mobile-ai-vault-resume-operations.ts @@ -4,7 +4,6 @@ import '../files/mobile-file-preview-operations' import { aiVaultResumePreparationSchema } from './mobile-ai-vault-resume-reply-schema' import '../browser/mobile-session-browser-reply-schema' import '../files/session-documents/mobile-file-tap-reply-schema' -import '../transport/rpc/rpc-uninterpreted-reply-schemas' // Legacy resume reads unsupported-method refusals raw to fall back to shared home. export const aiVaultResumePreparationRun = bindDeferredRpcOperation( diff --git a/mobile/src/browser/mobile-session-browser-operations.ts b/mobile/src/browser/mobile-session-browser-operations.ts index 597ef6fac7a..2dee93227b4 100644 --- a/mobile/src/browser/mobile-session-browser-operations.ts +++ b/mobile/src/browser/mobile-session-browser-operations.ts @@ -4,7 +4,6 @@ import '../files/mobile-file-preview-operations' import { browserTabCreatedSchema } from './mobile-session-browser-reply-schema' import '../agent-history/mobile-ai-vault-resume-reply-schema' import '../files/session-documents/mobile-file-tap-reply-schema' -import '../transport/rpc/rpc-uninterpreted-reply-schemas' export const sessionBrowserTabCreate = bindDeferredRpcOperation( defineRpcOperation({ diff --git a/mobile/src/files/native-chat-file-search-operations.ts b/mobile/src/files/native-chat-file-search-operations.ts index 44192e60de9..8f39e7d5873 100644 --- a/mobile/src/files/native-chat-file-search-operations.ts +++ b/mobile/src/files/native-chat-file-search-operations.ts @@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/r import { rpcResultVariant } from '../transport/rpc/rpc-operation-result-reader' import '../terminal/terminal-reply-schema' import { workspaceFilePathsSchema } from './workspace-file-paths-reply-schema' -import '../session/agent-launch/mobile-agent-detection-reply-schema' -import './session-documents/mobile-markdown-tab-reply-schema' -import '../transport/rpc/rpc-uninterpreted-reply-schemas' -import '../terminal/session/mobile-session-terminal-reply-schema' -import '../worktree/session-worktree-record-reply-schema' -import '../session/native-chat/composer/mobile-native-chat-skill-reply-schema' -import '../terminal/quick-commands/mobile-quick-command-reply-schema' // Skip refusals to retain suggestions; inspect method_not_found first to fall back from search to inventory. export const nativeChatFileSearchRead = bindDeferredRpcOperation( diff --git a/mobile/src/files/session-documents/mobile-file-tap-operations.ts b/mobile/src/files/session-documents/mobile-file-tap-operations.ts index ce347a864c1..d4da628703a 100644 --- a/mobile/src/files/session-documents/mobile-file-tap-operations.ts +++ b/mobile/src/files/session-documents/mobile-file-tap-operations.ts @@ -4,7 +4,6 @@ import '../mobile-file-preview-operations' import { fileTapOpenedSchema } from './mobile-file-tap-reply-schema' import '../../agent-history/mobile-ai-vault-resume-reply-schema' import '../../browser/mobile-session-browser-reply-schema' -import '../../transport/rpc/rpc-uninterpreted-reply-schemas' // Best-effort taps skip refused opens; explicit Changes-screen opens instead show the host error. export const fileTapOpenRun = bindDeferredRpcOperation( diff --git a/mobile/src/files/session-documents/mobile-markdown-tab-operations.ts b/mobile/src/files/session-documents/mobile-markdown-tab-operations.ts index 6978b9515cf..2d3d4a336d6 100644 --- a/mobile/src/files/session-documents/mobile-markdown-tab-operations.ts +++ b/mobile/src/files/session-documents/mobile-markdown-tab-operations.ts @@ -19,13 +19,6 @@ export const sessionMarkdownNoteCreate = bindDeferredRpcOperation( import '../../terminal/terminal-reply-schema' import { markdownTabDocumentSchema } from './mobile-markdown-tab-reply-schema' -import '../../session/agent-launch/mobile-agent-detection-reply-schema' - -import '../../terminal/session/mobile-session-terminal-reply-schema' -import '../../worktree/session-worktree-record-reply-schema' -import '../../session/native-chat/composer/mobile-native-chat-skill-reply-schema' -import '../../terminal/quick-commands/mobile-quick-command-reply-schema' -import '../workspace-file-paths-reply-schema' // Inspect renderer_unavailable before interpretation so headless hosts can fall back to disk. export const markdownTabRead = bindDeferredRpcOperation( @@ -38,8 +31,6 @@ export const markdownTabRead = bindDeferredRpcOperation( }) ) -import '../../session/tabs/mobile-session-tab-write-reply-schema' - import '../../terminal/quick-commands/mobile-quick-command-operations' export const markdownTabSave = bindDeferredRpcOperation( diff --git a/mobile/src/session/agent-launch/mobile-agent-detection-operations.ts b/mobile/src/session/agent-launch/mobile-agent-detection-operations.ts index 443525aca2d..a46d1f47914 100644 --- a/mobile/src/session/agent-launch/mobile-agent-detection-operations.ts +++ b/mobile/src/session/agent-launch/mobile-agent-detection-operations.ts @@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rp import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' import '../../terminal/terminal-reply-schema' import { detectedAgentsSchema, refreshedAgentsSchema } from './mobile-agent-detection-reply-schema' -import '../../files/session-documents/mobile-markdown-tab-reply-schema' -import '../../transport/rpc/rpc-uninterpreted-reply-schemas' -import '../../terminal/session/mobile-session-terminal-reply-schema' -import '../../worktree/session-worktree-record-reply-schema' -import '../native-chat/composer/mobile-native-chat-skill-reply-schema' -import '../../terminal/quick-commands/mobile-quick-command-reply-schema' -import '../../files/workspace-file-paths-reply-schema' // Launch detection surfaces host refusals; advisory task-drawer readers intentionally skip them. export const preflightDetectAgentsRead = bindDeferredRpcOperation( diff --git a/mobile/src/session/agent-launch/mobile-runtime-repo-operations.ts b/mobile/src/session/agent-launch/mobile-runtime-repo-operations.ts index cb885ee3fc6..246a1a00edb 100644 --- a/mobile/src/session/agent-launch/mobile-runtime-repo-operations.ts +++ b/mobile/src/session/agent-launch/mobile-runtime-repo-operations.ts @@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rp import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' import '../../terminal/terminal-reply-schema' import { runtimeRepoListSchema } from './mobile-agent-detection-reply-schema' -import '../../files/session-documents/mobile-markdown-tab-reply-schema' -import '../../transport/rpc/rpc-uninterpreted-reply-schemas' -import '../../terminal/session/mobile-session-terminal-reply-schema' -import '../../worktree/session-worktree-record-reply-schema' -import '../native-chat/composer/mobile-native-chat-skill-reply-schema' -import '../../terminal/quick-commands/mobile-quick-command-reply-schema' -import '../../files/workspace-file-paths-reply-schema' export type MobileRuntimeRepoSummary = { id: string diff --git a/mobile/src/session/floating-workspace.test.ts b/mobile/src/session/floating-workspace.test.ts index 8f29646b49b..056db4ca1db 100644 --- a/mobile/src/session/floating-workspace.test.ts +++ b/mobile/src/session/floating-workspace.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - FLOATING_WORKSPACE_WORKTREE_ID, - floatingWorkspaceSessionPath, - isFloatingWorkspaceWorktreeId -} from './floating-workspace' +import { isFloatingWorkspaceWorktreeId } from './floating-workspace' describe('floating workspace routing', () => { it('matches only the desktop sentinel id', () => { @@ -13,10 +9,4 @@ describe('floating workspace routing', () => { expect(isFloatingWorkspaceWorktreeId(undefined)).toBe(false) expect(isFloatingWorkspaceWorktreeId(null)).toBe(false) }) - - it('builds the session route with an explicit title seed', () => { - expect(floatingWorkspaceSessionPath('host-1')).toBe( - `/h/host-1/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=Floating%20Workspace` - ) - }) }) diff --git a/mobile/src/session/mobile-session-appearance-startup.test.tsx b/mobile/src/session/mobile-session-appearance-startup.test.tsx deleted file mode 100644 index afa67b42404..00000000000 --- a/mobile/src/session/mobile-session-appearance-startup.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { createElement } from 'react' -import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { MobileSessionRouteScreen } from './MobileSessionRouteScreen' - -// Mocked react-native components are string tags that ReactTestInstance's ElementType omits. -function hostType(node: ReactTestInstance): unknown { - return node.type -} - -const state = vi.hoisted(() => ({ - terminalPreferencesReady: false, - accessoryPreferencesReady: false, - nativeChatController: { sessionViewReady: false } -})) -const mounts = vi.hoisted(() => ({ count: 0 })) -vi.mock('react-native', () => ({ - ActivityIndicator: 'ActivityIndicator', - Text: 'Text', - View: 'View' -})) -vi.mock('./mobile-session-styles', () => ({ styles: {} })) -vi.mock('./use-mobile-session-controller', () => ({ useMobileSessionController: () => state })) -vi.mock('./MobileSessionSurface', async () => { - const react = await import('react') - return { - MobileSessionSurface: () => { - react.useEffect(() => { - mounts.count += 1 - }, []) - return react.createElement('ReadySession') - } - } -}) - -let renderer: ReactTestRenderer | null = null -afterEach(() => { - act(() => renderer?.unmount()) - renderer = null -}) - -describe('first visible session appearance', () => { - it.each(['terminal', 'accessory', 'chat'] as const)( - 'waits for the saved %s setting before mounting the session', - (waiting) => { - state.terminalPreferencesReady = waiting !== 'terminal' - state.accessoryPreferencesReady = waiting !== 'accessory' - state.nativeChatController.sessionViewReady = waiting !== 'chat' - mounts.count = 0 - const render = () => - act(() => { - if (renderer) { - renderer.update(createElement(MobileSessionRouteScreen)) - } else { - renderer = create(createElement(MobileSessionRouteScreen)) - } - }) - const expectPreparing = () => { - expect(renderer?.root.findAll((node) => hostType(node) === 'ReadySession')).toHaveLength(0) - expect( - renderer?.root.findAll((node) => hostType(node) === 'ActivityIndicator') - ).toHaveLength(1) - expect(mounts.count).toBe(0) - } - render() - expectPreparing() - state.terminalPreferencesReady = true - state.accessoryPreferencesReady = true - state.nativeChatController.sessionViewReady = true - render() - expect(renderer?.root.findAll((node) => hostType(node) === 'ReadySession')).toHaveLength(1) - expect(mounts.count).toBe(1) - render() - expect(mounts.count).toBe(1) - } - ) -}) diff --git a/mobile/src/session/mobile-session-status-result.ts b/mobile/src/session/mobile-session-status-result.ts deleted file mode 100644 index 722e62ab480..00000000000 --- a/mobile/src/session/mobile-session-status-result.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type RuntimeStatusResult = { - capabilities?: string[] -} diff --git a/mobile/src/session/native-chat/MobileNativeChatView.turn-status.test.ts b/mobile/src/session/native-chat/MobileNativeChatView.turn-status.test.ts deleted file mode 100644 index b8dcf5eda98..00000000000 --- a/mobile/src/session/native-chat/MobileNativeChatView.turn-status.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import './MobileNativeChatView.harness.test-fixture' -import { createElement, type ReactNode } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types' -import { MobileNativeChatView } from './MobileNativeChatView' - -const scrollToEnd = vi.hoisted(() => vi.fn()) -const scrollToOffset = vi.hoisted(() => vi.fn()) - -vi.mock('react-native', async () => { - const React = await import('react') - return { - ...(await import('../../test-support/react-native-motion-stub')).reactNativeMotionStub(), - ActivityIndicator: 'ActivityIndicator', - FlatList: React.forwardRef< - unknown, - { - data: NativeChatMessage[] - renderItem: (row: { item: NativeChatMessage; index: number }) => ReactNode - ListFooterComponent: ReactNode - } - >((props, ref) => { - React.useImperativeHandle(ref, () => ({ scrollToEnd, scrollToOffset }), []) - return React.createElement( - 'FlatList', - props, - props.data.map((item, index) => - React.createElement(React.Fragment, { key: item.id }, props.renderItem({ item, index })) - ), - props.ListFooterComponent - ) - }), - Platform: { OS: 'ios' }, - Pressable: 'Pressable', - StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, - Text: 'Text', - View: 'View' - } -}) - -vi.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) -})) - -vi.mock('react-native-gesture-handler', () => { - const chain = { - runOnJS: () => chain, - onStart: () => chain, - onUpdate: () => chain - } - return { - Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, - GestureDetector: 'GestureDetector', - GestureHandlerRootView: 'GestureHandlerRootView' - } -}) - -vi.mock('lucide-react-native', () => ({ - ArrowDown: 'ArrowDown', - ChevronsDownUp: 'ChevronsDownUp', - ChevronsUpDown: 'ChevronsUpDown', - Square: 'Square' -})) - -vi.mock('./transcript/MobileNativeChatMessage', () => ({ - MobileNativeChatMessage: 'ChatMessage' -})) -vi.mock('./transcript/MobileNativeChatLiveLine', () => ({ - MobileNativeChatLiveLine: 'LiveStatus' -})) -vi.mock('./input/MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) -vi.mock('./input/MobileNativeChatPermission', () => ({ - MobileNativeChatPermission: 'ChatPermission' -})) -vi.mock('./input/MobileNativeChatQuestion', () => ({ - MobileNativeChatQuestion: 'ChatQuestion' -})) -vi.mock('../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) - -vi.mock('./composer/MobileNativeChatComposer', async () => { - const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') - return composerStub() -}) - -type Overrides = { - messages?: Parameters[0]['messages'] - folded?: Parameters[0]['folded'] - streaming?: string | null - sendErrorMessage?: string | null - onClearSendError?: () => void - inputLockReason?: 'disconnected' | 'waiting' | null - onSend?: (text: string) => Promise - pending?: Parameters[0]['pending'] - structuredActivityUi?: boolean - turnIndicator?: Parameters[0]['turnIndicator'] - agentWorking?: boolean - canStop?: boolean - ask?: Parameters[0]['ask'] - question?: Parameters[0]['question'] - permission?: Parameters[0]['permission'] - sendSurfaceId?: string - keyboardInset?: number - hasMore?: boolean - onLoadEarlier?: () => void -} - -function chatViewElement(overrides: Overrides): ReturnType { - return createElement(MobileNativeChatView, { - messages: [], - folded: [], - status: 'ready', - streaming: null, - onSend: vi.fn().mockResolvedValue(true), - sendSurfaceId: 'tab-a', - getSendCompletionGeneration: () => 0, - getComposerEditGeneration: () => 0, - pending: [], - draftKey: null, - onComposerTextChange: vi.fn(), - ...overrides - }) -} - -describe('MobileNativeChatView', () => { - let renderer: ReactTestRenderer | null = null - - beforeEach(() => { - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - setTimeout(() => callback(0), 0) - ) - vi.stubGlobal('cancelAnimationFrame', (handle: ReturnType) => - clearTimeout(handle) - ) - }) - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - scrollToEnd.mockReset() - scrollToOffset.mockReset() - vi.unstubAllGlobals() - }) - - async function render(overrides: Overrides = {}): Promise { - await act(async () => { - renderer = create(chatViewElement(overrides)) - }) - } - - async function update(overrides: Overrides = {}): Promise { - await act(async () => { - renderer?.update(chatViewElement(overrides)) - }) - } - - function renderedRow(id: string): ReturnType { - return createElement( - 'Row', - renderer!.root.find( - (node) => String(node.type) === 'ChatMessage' && node.props.message.id === id - ).props - ) - } - - describe('structured turn status wiring', () => { - const userTurn = (id: string, text: string): NativeChatMessage => ({ - id, - role: 'user', - blocks: [{ type: 'text', text }], - timestamp: 0, - source: 'transcript' - }) - - function rowProps(id: string): Record { - return (renderedRow(id) as { props: Record }).props - } - - it('does not carry a running turn clock across chat surfaces', async () => { - vi.useFakeTimers() - try { - vi.setSystemTime(1_000) - const firstTab = [userTurn('u1', 'first')] - await render({ - messages: firstTab, - folded: firstTab, - structuredActivityUi: true, - agentWorking: true, - sendSurfaceId: 'host\0worktree\0tab-a' - }) - expect(rowProps('u1').turnStatus).toMatchObject({ startedAt: 1_000 }) - - vi.setSystemTime(12_000) - const secondTab = [userTurn('u2', 'second')] - await update({ - messages: secondTab, - folded: secondTab, - structuredActivityUi: true, - agentWorking: true, - sendSurfaceId: 'host\0worktree\0tab-b' - }) - - expect(rowProps('u2').turnStatus).toMatchObject({ startedAt: 12_000 }) - } finally { - vi.useRealTimers() - } - }) - }) -}) - -vi.mock('./input/MobileQueuedMessageComposer', () => ({ - MobileQueuedMessageComposer: 'QueuedMessageComposer' -})) diff --git a/mobile/src/session/native-chat/agent-session/mobile-native-chat-page-read-operations.ts b/mobile/src/session/native-chat/agent-session/mobile-native-chat-page-read-operations.ts index b91d5156f2d..f7a712d7aba 100644 --- a/mobile/src/session/native-chat/agent-session/mobile-native-chat-page-read-operations.ts +++ b/mobile/src/session/native-chat/agent-session/mobile-native-chat-page-read-operations.ts @@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../../transport import { rpcResultVariant } from '../../../transport/rpc/rpc-operation-result-reader' import '../../../terminal/terminal-reply-schema' import { sessionForwardedReplySchema } from '../../../transport/rpc/rpc-uninterpreted-reply-schemas' -import '../../agent-launch/mobile-agent-detection-reply-schema' -import '../../../files/session-documents/mobile-markdown-tab-reply-schema' -import '../../../terminal/session/mobile-session-terminal-reply-schema' -import '../../../worktree/session-worktree-record-reply-schema' -import '../composer/mobile-native-chat-skill-reply-schema' -import '../../../terminal/quick-commands/mobile-quick-command-reply-schema' -import '../../../files/workspace-file-paths-reply-schema' // Retain the live window on refusal and let the caller offer retry; read the whole legacy error/window union before selecting messages. export const nativeChatSessionPageRead = bindDeferredRpcOperation( diff --git a/mobile/src/session/native-chat/composer/mobile-draft-inputs-scaling.test.ts b/mobile/src/session/native-chat/composer/mobile-draft-inputs-scaling.test.ts deleted file mode 100644 index 8155332bd37..00000000000 --- a/mobile/src/session/native-chat/composer/mobile-draft-inputs-scaling.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { createElement } from 'react' -import { act, create } from 'react-test-renderer' -import { afterEach, expect, it, vi } from 'vitest' -import { useMobileNativeChatFileAttachments } from './use-mobile-native-chat-file-attachments' -import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' -import { - useMobileNativeChatDraft, - setMobileNativeChatDraftText, - resetMobileNativeChatDraftStoreForTests -} from './mobile-native-chat-draft-store' -import { - subscribeMobileNativeChatDraftInputs, - updateMobileNativeChatDraftInputs, - closeMobileNativeChatEditInputs, - useMobileNativeChatDraftInputs -} from './mobile-native-chat-draft-inputs' - -const picker = vi.hoisted(() => ({ pickFiles: vi.fn(), pickImages: vi.fn() })) -vi.mock('../../../platform/media-picker', () => ({ useMediaPicker: () => picker })) -afterEach(resetMobileNativeChatDraftStoreForTests) - -it.each([20, 200])( - 'isolates mounted text, input, image and file consumers across %i other scopes', - async (count) => { - const scopes = [ - 'own-host:target', - ...Array.from({ length: count }, (_, i) => `other-host:${i}`) - ] - const own = scopes[0] - const renders = scopes.map(() => ({ text: 0, input: 0, image: 0, file: 0 })) - const values: { - text: string - pending: number - images: readonly string[] - files: readonly string[] - }[] = scopes.map(() => ({ text: '', pending: 0, images: [], files: [] })) - const upstream = scopes.map(() => vi.fn()) - const releases = scopes.map((scope, i) => - subscribeMobileNativeChatDraftInputs(scope, upstream[i]) - ) - function Text({ index }: { index: number }) { - values[index].text = useMobileNativeChatDraft(scopes[index]) - renders[index].text++ - return null - } - function Input({ index }: { index: number }) { - values[index].pending = useMobileNativeChatDraftInputs(scopes[index]).pending ?? 0 - renders[index].input++ - return null - } - function File({ index }: { index: number }) { - const file = useMobileNativeChatFileAttachments({ - client: null, - sessionId: null, - scopeKey: scopes[index], - enabled: false, - showToast: vi.fn() - }) - values[index].files = file.files.map((entry) => entry.path ?? '') - renders[index].file++ - return null - } - function Image({ index }: { index: number }) { - const image = useMobileNativeChatImageAttachments({ - client: null, - agent: 'codex', - activeHandleRef: { current: null }, - deviceTokenRef: { current: null }, - getActiveWorktreeConnectionId: async () => null, - connState: 'disconnected', - scopeKey: scopes[index], - enabled: false, - structuredNativeChat: true, - showToast: vi.fn(), - onSendError: vi.fn(), - baseSend: async () => 'rejected', - readSeededLaunchDraft: () => null - }) - values[index].images = image.attachments.map((entry) => entry.path) - renders[index].image++ - return null - } - let view: ReturnType | undefined - await act(async () => { - view = create( - createElement( - 'fixture', - {}, - scopes.flatMap((_, index) => [ - createElement(Text, { key: `t${index}`, index }), - createElement(Input, { key: `p${index}`, index }), - createElement(File, { key: `f${index}`, index }), - createElement(Image, { key: `i${index}`, index }) - ]) - ) - ) - }) - renders.forEach((entry) => { - entry.text = entry.input = entry.image = entry.file = 0 - }) - try { - await act(async () => setMobileNativeChatDraftText(own, 'own text')) - expect(values[0].text).toBe('own text') - expect(renders[0]).toEqual({ text: 1, input: 0, image: 0, file: 0 }) - await act(async () => - updateMobileNativeChatDraftInputs(own, (current) => ({ ...current, pending: 1 })) - ) - expect(values[0].pending).toBe(1) - await act(async () => - updateMobileNativeChatDraftInputs(own, (current) => ({ - ...current, - images: [{ id: 'image', path: '/saved.png', previewUri: 'file:///saved.png' }] - })) - ) - expect(values[0].images).toEqual(['/saved.png']) - await act(async () => - updateMobileNativeChatDraftInputs(own, (current) => ({ - ...current, - files: [ - { id: 'file', path: '/saved.txt', name: 'saved.txt', mime: 'text/plain', byteLength: 1 } - ] - })) - ) - expect(values[0].files).toEqual(['/saved.txt']) - expect(renders[0]).toEqual({ text: 1, input: 3, image: 2, file: 1 }) - expect(upstream[0]).toHaveBeenCalledTimes(3) - for (let index = 1; index < scopes.length; index++) { - expect(renders[index]).toEqual({ text: 0, input: 0, image: 0, file: 0 }) - expect(upstream[index]).not.toHaveBeenCalled() - } - } finally { - await act(async () => view?.unmount()) - releases.forEach((release) => release()) - } - await act(async () => - updateMobileNativeChatDraftInputs(own, (current) => ({ ...current, pending: 0 })) - ) - expect(renders[0]).toEqual({ text: 1, input: 3, image: 2, file: 1 }) - expect(upstream[0]).toHaveBeenCalledTimes(3) - } -) -it.each([20, 200])( - 'notifies only affected scopes across %i retained input subscribers and retires listeners', - (count) => { - const idle = vi.fn() - const releases = Array.from({ length: count }, (_, index) => - subscribeMobileNativeChatDraftInputs(`idle-${index}`, idle) - ) - const source = vi.fn() - const destination = vi.fn() - releases.push( - subscribeMobileNativeChatDraftInputs('queued-edit:one', source), - subscribeMobileNativeChatDraftInputs('bottom', destination) - ) - updateMobileNativeChatDraftInputs('queued-edit:one', (inputs) => ({ ...inputs, pending: 1 })) - expect(source).toHaveBeenCalledTimes(1) - expect(idle).not.toHaveBeenCalled() - closeMobileNativeChatEditInputs('queued-edit:one', 'bottom') - expect(source).toHaveBeenCalledTimes(2) - expect(destination).toHaveBeenCalledTimes(1) - expect(idle).not.toHaveBeenCalled() - releases.forEach((release) => release()) - updateMobileNativeChatDraftInputs('bottom', (inputs) => ({ ...inputs, pending: 0 })) - expect(destination).toHaveBeenCalledTimes(1) - } -) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-file-attachment.test.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-file-attachment.test.ts index c6a04295615..37a0d18ae6a 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-file-attachment.test.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-file-attachment.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - MobileFileTooLargeError, - type PickedMobileFile -} from '../../../platform/media-picker-contract' +import type { PickedMobileFile } from '../../../platform/media-picker-contract' import { nativeChatTextWithFileReferences, uploadPickedNativeChatFiles, @@ -83,20 +80,4 @@ describe('uploadPickedNativeChatFiles', () => { expect([...chips.values()].map((chip) => chip.path)).toEqual(['/store/a.txt', '/store/b.txt']) expect(errors).toEqual([]) }) - - it('drops the chip of a failed upload, says which file, and carries on', async () => { - const { chips, errors, done } = run(pickedFiles([picked('a.txt'), picked('b.txt')]), 'a.txt') - await done - expect([...chips.values()].map((chip) => chip.name)).toEqual(['b.txt']) - expect(errors).toEqual(["Couldn't upload a.txt"]) - }) - - it('keeps what was stored before the picker refused an oversized file', async () => { - const { chips, errors, done } = run( - pickedFiles([picked('a.txt')], new MobileFileTooLargeError('clip.mov')) - ) - await done - expect([...chips.values()].map((chip) => chip.name)).toEqual(['a.txt']) - expect(errors).toEqual([new MobileFileTooLargeError('clip.mov').message]) - }) }) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-image-scope-state.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-image-scope-state.ts deleted file mode 100644 index 3429717ed67..00000000000 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-image-scope-state.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' - -export const NO_NATIVE_CHAT_IMAGE_ATTACHMENTS: PendingNativeChatImage[] = [] - -export type MobileNativeChatImagesByScope = Record - -export function withScopeAttachments( - byScope: Readonly>, - scope: string, - next: List -): Record { - if (next.length > 0) { - return { ...byScope, [scope]: next } - } - const remaining = { ...byScope } - delete remaining[scope] - return remaining -} diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-skill-operations.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-skill-operations.ts index fc06f8e0be0..de98372945e 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-skill-operations.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-skill-operations.ts @@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../../transport import { rpcResultVariant } from '../../../transport/rpc/rpc-operation-result-reader' import '../../../terminal/terminal-reply-schema' import { skillDiscoveryReplySchema } from './mobile-native-chat-skill-reply-schema' -import '../../agent-launch/mobile-agent-detection-reply-schema' -import '../../../files/session-documents/mobile-markdown-tab-reply-schema' -import '../../../transport/rpc/rpc-uninterpreted-reply-schemas' -import '../../../terminal/session/mobile-session-terminal-reply-schema' -import '../../../worktree/session-worktree-record-reply-schema' -import '../../../terminal/quick-commands/mobile-quick-command-reply-schema' -import '../../../files/workspace-file-paths-reply-schema' // Mobile names the workspace and lets the host resolve and scan it; see SKILL_DISCOVER_WORKSPACE_CAPABILITY. export const nativeChatSkillDiscoveryRead = bindDeferredRpcOperation( diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.test.ts index 3545d6e078a..0a35b683597 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.test.ts @@ -354,52 +354,6 @@ describe('useMobileNativeChatDrafts', () => { expect(state?.composerText).toBe(' /clear') }) - it('reconciles an image-only unconfirmed send against the next user turn (no false warning)', async () => { - vi.useFakeTimers() - try { - await mount('a') - await act(async () => - renderer?.update( - createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] }) - ) - ) - const origin = state?.captureSendOrigin('') - const onUnconfirmed = vi.fn() - act(() => { - if (origin) { - state?.holdUnconfirmedSend(origin, '', onUnconfirmed) - } - }) - - await act(async () => - renderer?.update( - createElement(Harness, { - tabId: 'a', - messages: [assistantTextMessage('a1', 'hi'), assistantTextMessage('a2', 'ok')] - }) - ) - ) - await act(async () => - renderer?.update( - createElement(Harness, { - tabId: 'a', - messages: [ - assistantTextMessage('a1', 'hi'), - assistantTextMessage('a2', 'ok'), - userTextMessage('u1', '') - ] - }) - ) - ) - act(() => { - vi.advanceTimersByTime(30_000) - }) - expect(onUnconfirmed).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - it('clears image-only echoes one per landed user turn, not all at once', async () => { await mount('a') await act(async () => @@ -430,59 +384,6 @@ describe('useMobileNativeChatDrafts', () => { expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///b.jpg']]) }) - it('registers no deadline when the transcript echo beat the ambiguous RPC rejection', async () => { - vi.useFakeTimers() - try { - await mount('a') - const origin = state?.captureSendOrigin('ping') - const onUnconfirmed = vi.fn() - - await act(async () => - renderer?.update( - createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] }) - ) - ) - act(() => { - if (origin) { - state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed) - } - }) - - expect(vi.getTimerCount()).toBe(0) - act(() => { - vi.advanceTimersByTime(30_000) - }) - expect(onUnconfirmed).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('surfaces uncertainty when no echo lands before the deadline', async () => { - vi.useFakeTimers() - try { - await mount('a') - const origin = state?.captureSendOrigin('ping') - const onUnconfirmed = vi.fn() - act(() => { - if (origin) { - state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed) - } - }) - - act(() => { - vi.advanceTimersByTime(19_999) - }) - expect(onUnconfirmed).not.toHaveBeenCalled() - act(() => { - vi.advanceTimersByTime(1) - }) - expect(onUnconfirmed).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - it('does not confirm an unconfirmed send against an older identical turn', async () => { vi.useFakeTimers() try { @@ -556,63 +457,6 @@ describe('useMobileNativeChatDrafts', () => { } }) - it('requires one new transcript echo per repeated unconfirmed send', async () => { - vi.useFakeTimers() - try { - await mount('a') - act(() => state?.setComposerText('ping')) - const origin = state?.captureSendOrigin('ping') - const firstUnconfirmed = vi.fn() - const secondUnconfirmed = vi.fn() - act(() => { - if (origin) { - state?.holdUnconfirmedSend(origin, 'ping', firstUnconfirmed) - state?.holdUnconfirmedSend(origin, 'ping', secondUnconfirmed) - } - }) - - await act(async () => - renderer?.update( - createElement(Harness, { tabId: 'a', messages: [userTextMessage('echo-1', 'ping')] }) - ) - ) - act(() => { - vi.advanceTimersByTime(30_000) - }) - - expect(firstUnconfirmed).not.toHaveBeenCalled() - expect(secondUnconfirmed).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - - it('does not retain a deadline when an ambiguous send settles after unmount', async () => { - vi.useFakeTimers() - try { - await mount('a') - const origin = state?.captureSendOrigin('ping') - const holdUnconfirmedSend = state?.holdUnconfirmedSend - const onUnconfirmed = vi.fn() - act(() => renderer?.unmount()) - renderer = null - - act(() => { - if (origin) { - holdUnconfirmedSend?.(origin, 'ping', onUnconfirmed) - } - }) - - expect(vi.getTimerCount()).toBe(0) - act(() => { - vi.advanceTimersByTime(30_000) - }) - expect(onUnconfirmed).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - it('does not erase newer edits when an unconfirmed send lands', async () => { await mount('a') act(() => state?.setComposerText('submitted')) diff --git a/mobile/src/session/native-chat/input/MobileNativeChatBackgroundTasks.test.tsx b/mobile/src/session/native-chat/input/MobileNativeChatBackgroundTasks.test.tsx index 8e39cddc41d..55bd4868066 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatBackgroundTasks.test.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatBackgroundTasks.test.tsx @@ -52,7 +52,6 @@ type Props = Parameters[0] type StopResult = Awaited> const REJECTED: StopResult = { status: 'rejected' } -const UNCONFIRMED: StopResult = { status: 'unknown' } const STOPPED: StopResult = { status: 'accepted', value: { cancelled: true }, sameFence: true } const NOTHING_STOPPED: StopResult = { status: 'accepted', @@ -98,20 +97,6 @@ describe('MobileNativeChatBackgroundTasks', () => { return mounted } - function styleOf(node: ReactTestInstance): Record { - const rendered: unknown = - typeof node.props.style === 'function' - ? node.props.style({ pressed: false }) - : node.props.style - const parts = Array.isArray(rendered) ? rendered : [rendered] - return Object.assign( - {}, - ...parts.filter( - (part): part is Record => typeof part === 'object' && part !== null - ) - ) - } - function header(mounted: ReactTestRenderer): ReactTestInstance { return mounted.root.findAll( (node) => @@ -132,31 +117,6 @@ describe('MobileNativeChatBackgroundTasks', () => { ) } - it('stops one row by its provider id and holds its button while the Stop is on its way', async () => { - let finish: (result: StopResult) => void = () => {} - const stop = vi.fn(() => new Promise((resolve) => (finish = resolve))) - const mounted = mount( - tasksFor( - { - state: 'monitoring', - supportsTaskStop: true, - children: [view('a'), view('b', { stoppable: false })] - }, - { stop } - ) - ) - expand(mounted) - const buttons = stopButtons(mounted) - expect(buttons.map((button) => button.props.accessibilityLabel)).toEqual(['Stop review a']) - act(() => buttons[0]!.props.onPress()) - act(() => buttons[0]!.props.onPress()) - expect(stop).toHaveBeenCalledTimes(1) - expect(stop).toHaveBeenCalledWith('task-a') - expect(stopButtons(mounted)[0]!.props.disabled).toBe(true) - await act(async () => finish(UNCONFIRMED)) - expect(stopButtons(mounted)[0]!.props.disabled).toBe(false) - }) - it('keeps a confirmed Stop held until its row leaves, and offers it again if the row comes back', async () => { let finish: (result: StopResult) => void = () => {} const stop = vi.fn(() => new Promise((resolve) => (finish = resolve))) @@ -206,30 +166,4 @@ describe('MobileNativeChatBackgroundTasks', () => { }) expect(stopButtons(mounted)[0]!.props.disabled).toBe(false) }) - - it('offers Stop all only to a host with no per-row stop that still accepts one', async () => { - let finish: (result: StopResult) => void = () => {} - const stop = vi.fn(() => new Promise((resolve) => (finish = resolve))) - const fallback = mount(tasksFor({ state: 'monitoring', children: [view('a')] }, { stop })) - expand(fallback) - expect(stopButtons(fallback).map((button) => button.props.accessibilityLabel)).toEqual([ - 'Stop background tasks' - ]) - const button = stopButtons(fallback)[0]! - expect(styleOf(button)).toMatchObject({ minHeight: 32, minWidth: 44 }) - expect(button.props.hitSlop).toBe(6) - expect(Number(styleOf(button).minHeight) + 2 * button.props.hitSlop).toBe(44) - act(() => button.props.onPress()) - act(() => button.props.onPress()) - expect(stop).toHaveBeenCalledTimes(1) - expect(stop).toHaveBeenCalledWith(undefined) - expect(stopButtons(fallback)[0]!.props.disabled).toBe(true) - await act(async () => finish(STOPPED)) - expect(stopButtons(fallback)[0]!.props.disabled).toBe(false) - const none = mount( - tasksFor({ state: 'monitoring', supportsStopAll: false, children: [view('a')] }) - ) - expand(none) - expect(stopButtons(none)).toEqual([]) - }) }) diff --git a/mobile/src/session/native-chat/input/mobile-native-chat-answer-stepping.test.ts b/mobile/src/session/native-chat/input/mobile-native-chat-answer-stepping.test.ts deleted file mode 100644 index ed8d3c730ea..00000000000 --- a/mobile/src/session/native-chat/input/mobile-native-chat-answer-stepping.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - mobileNativeChatQuestionOffsets, - scheduleMobileClaudeAnswer, - MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS, - MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS -} from './mobile-native-chat-answer-stepping' - -describe('mobileNativeChatQuestionOffsets', () => { - it('paces each question a full step apart, Enter 500ms after its body', () => { - expect(mobileNativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 }) - expect(mobileNativeChatQuestionOffsets(1)).toEqual({ bodyAt: 1000, enterAt: 1500 }) - expect(mobileNativeChatQuestionOffsets(2)).toEqual({ bodyAt: 2000, enterAt: 2500 }) - }) -}) - -describe('scheduleMobileClaudeAnswer', () => { - beforeEach(() => vi.useFakeTimers()) - afterEach(() => vi.useRealTimers()) - - it('writes each question body then its Enter, paced per step, one Enter per line', () => { - const events: string[] = [] - const timers = scheduleMobileClaudeAnswer( - ['', 'b', 'c'], - (line) => events.push(`body:${line}`), - () => events.push('enter') - ) - expect(timers).toHaveLength(6) - - vi.advanceTimersByTime(0) - expect(events).toEqual(['body:']) - vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS - 1) - expect(events).toEqual(['body:']) - vi.advanceTimersByTime(1) - expect(events).toEqual(['body:', 'enter']) - vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS) - expect(events).toEqual(['body:', 'enter', 'body:b']) - - vi.runAllTimers() - expect(events).toEqual(['body:', 'enter', 'body:b', 'enter', 'body:c', 'enter']) - }) - - it('cancelling the returned timers stops all pending writes', () => { - const events: string[] = [] - const timers = scheduleMobileClaudeAnswer( - ['a', 'b'], - (line) => events.push(`body:${line}`), - () => events.push('enter') - ) - for (const timer of timers) { - clearTimeout(timer) - } - vi.runAllTimers() - expect(events).toEqual([]) - }) -}) diff --git a/mobile/src/session/native-chat/input/mobile-native-chat-answer-stepping.ts b/mobile/src/session/native-chat/input/mobile-native-chat-answer-stepping.ts deleted file mode 100644 index ad105d55a74..00000000000 --- a/mobile/src/session/native-chat/input/mobile-native-chat-answer-stepping.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { - nativeChatQuestionOffsets, - scheduleNativeChatAnswer, - NATIVE_CHAT_ADVANCE_BUFFER_MS, - NATIVE_CHAT_QUESTION_STEP_MS, - NATIVE_CHAT_SUBMIT_DELAY_MS -} from '../../../../../src/shared/native-chat/input/native-chat-answer-stepping' - -export const MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS = NATIVE_CHAT_ADVANCE_BUFFER_MS -export const MOBILE_NATIVE_CHAT_QUESTION_STEP_MS = NATIVE_CHAT_QUESTION_STEP_MS -export const MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS = NATIVE_CHAT_SUBMIT_DELAY_MS -export const mobileNativeChatQuestionOffsets = nativeChatQuestionOffsets -export const scheduleMobileClaudeAnswer = scheduleNativeChatAnswer diff --git a/mobile/src/session/native-chat/input/mobile-native-chat-answered-prompts.test-fixture.ts b/mobile/src/session/native-chat/input/mobile-native-chat-answered-prompts.test-fixture.ts deleted file mode 100644 index 829ffd49bda..00000000000 --- a/mobile/src/session/native-chat/input/mobile-native-chat-answered-prompts.test-fixture.ts +++ /dev/null @@ -1,261 +0,0 @@ -// A structured Claude chat on the desktop, read back with `agentSession.history` after its owner -// approved a Bash call and answered an AskUserQuestion (Claude Code 2.1.300, 2026-10-15). - -import type { AgentJournalRenderItem } from '../../../../../src/shared/agent-session/journal/agent-session-journal-types' - -export const ANSWERED_PROMPT_ITEMS: AgentJournalRenderItem[] = [ - { - itemId: 'orca:1792024009236-87ed41e5430c47008ef7978ca5d60505', - revision: 0, - body: { - kind: 'message', - role: 'user', - blocks: [ - { - type: 'text', - text: 'Run the bash command: echo receipt-check > receipt.txt' - } - ] - }, - sequence: 43, - observedAt: 1792024010442, - turnScope: { - kind: 'thread' - } - }, - { - itemId: - 'orca:claude-tool%3A2ee61cec-1207-4f44-a915-b6adbcbf169b%3Atoolu_01FhhC72BuRiD3d9hXkAsURD', - revision: 2, - body: { - kind: 'tool-call', - name: 'Bash', - input: { - command: 'echo receipt-check > receipt.txt', - description: 'Write receipt-check to receipt.txt' - }, - callId: 'toolu_01FhhC72BuRiD3d9hXkAsURD', - state: 'completed', - output: { - head: '(Bash completed with no output)', - byteLength: 31, - digest: 'cac1685585590361647e7a27b58a25a590f335bfcd0ab70fcdc0424e40c20843', - truncated: false - } - }, - sequence: 46, - observedAt: 1792024014854, - turnScope: { - kind: 'turn', - turnItemId: - 'legacy:claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:turn-lifecycle%3A6a80bc01-4be4-466f-bd98-5cae8368ed1d' - } - }, - { - itemId: - 'orca:claude-prompt%3Aclaude_c65b4304_2acf_4eec_a749_e06876328e0c%3A79c45210-730e-482c-82bd-db0708c992e5', - revision: 2, - body: { - kind: 'approval', - title: 'Allow Bash?', - displayName: 'Bash', - description: 'Write receipt-check to receipt.txt', - blockedPath: '/private/tmp/mnw-scratch/receipt.txt', - detail: - '{\n "command": "echo receipt-check > receipt.txt",\n "description": "Write receipt-check to receipt.txt"\n}', - options: [ - { - id: 'allow', - label: 'Allow' - }, - { - id: 'allowForSession', - label: 'Allow for this session' - }, - { - id: 'deny', - label: 'Deny' - } - ], - resolution: { - state: 'resolved', - selectedOptionId: 'allow', - resolvedBy: 'desktop-renderer', - resolvedAt: 1792024037937 - } - }, - sequence: 48, - observedAt: 1792024014910, - turnScope: { - kind: 'turn', - turnItemId: - 'legacy:claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:turn-lifecycle%3A6a80bc01-4be4-466f-bd98-5cae8368ed1d' - } - }, - { - itemId: 'claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:4408cf7b-bbe2-4a5d-aa80-6471db6d9c18', - revision: 2, - body: { - kind: 'message', - role: 'assistant', - blocks: [ - { - type: 'text', - text: 'I ran the command. `receipt.txt` in `/private/tmp/mnw-scratch` now holds the line `receipt-check`.' - } - ] - }, - sequence: 51, - observedAt: 1792024040901, - turnScope: { - kind: 'turn', - turnItemId: - 'legacy:claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:turn-lifecycle%3A6a80bc01-4be4-466f-bd98-5cae8368ed1d' - } - }, - { - itemId: 'orca:1792024048049-e58989ce33eb411a970225d03d2e4a18', - revision: 0, - body: { - kind: 'message', - role: 'user', - blocks: [ - { - type: 'text', - text: 'Use the AskUserQuestion tool to ask me whether I prefer tea or coffee, then reply with my answer in one short sentence.' - } - ] - }, - sequence: 57, - observedAt: 1792024048075, - turnScope: { - kind: 'thread' - } - }, - { - itemId: - 'orca:claude-tool%3A2ee61cec-1207-4f44-a915-b6adbcbf169b%3Atoolu_01WByTgphD4HFByq9zPKXn2J', - revision: 2, - body: { - kind: 'tool-call', - name: 'AskUserQuestion', - input: { - questions: [ - { - question: 'Do you prefer tea or coffee?', - header: 'Drink', - multiSelect: false, - options: [ - { - label: 'Tea', - description: 'You prefer tea.' - }, - { - label: 'Coffee', - description: 'You prefer coffee.' - } - ] - } - ] - }, - callId: 'toolu_01WByTgphD4HFByq9zPKXn2J', - state: 'completed', - output: { - head: 'Your questions have been answered: "Do you prefer tea or coffee?"="Coffee". You can now continue with these answers in mind.', - byteLength: 124, - digest: '65dfb0af315c9ab1c70fc26c6c6bfb4ef0138216e35c89cf332927d10cbb629d', - truncated: false - } - }, - sequence: 60, - observedAt: 1792024052205, - turnScope: { - kind: 'turn', - turnItemId: - 'legacy:claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:turn-lifecycle%3A1ea7cf09-10c6-4635-93bb-8d4b16fb1c60' - } - }, - { - itemId: - 'orca:claude-prompt%3Aclaude_c65b4304_2acf_4eec_a749_e06876328e0c%3A6fd8be6a-830f-44b4-a3ef-a7f044cd4a4f', - revision: 2, - body: { - kind: 'question', - question: 'Do you prefer tea or coffee?', - options: [ - { - id: 'q1:choice-1', - label: 'Tea', - description: 'You prefer tea.' - }, - { - id: 'q1:choice-2', - label: 'Coffee', - description: 'You prefer coffee.' - } - ], - freeTextQuestionId: 'q1', - questions: [ - { - id: 'q1', - question: 'Do you prefer tea or coffee?', - header: 'Drink', - multiSelect: false, - options: [ - { - id: 'q1:choice-1', - label: 'Tea', - description: 'You prefer tea.' - }, - { - id: 'q1:choice-2', - label: 'Coffee', - description: 'You prefer coffee.' - } - ], - freeTextQuestionId: 'q1' - } - ], - resolution: { - state: 'resolved', - selectedOptionId: 'question-group:[{"questionId":"q1","optionIds":["q1:choice-2"]}]', - answers: [ - { - questionId: 'q1', - optionIds: ['q1:choice-2'] - } - ], - resolvedBy: 'desktop-renderer', - resolvedAt: 1792024081980 - } - }, - sequence: 62, - observedAt: 1792024052228, - turnScope: { - kind: 'turn', - turnItemId: - 'legacy:claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:turn-lifecycle%3A1ea7cf09-10c6-4635-93bb-8d4b16fb1c60' - } - }, - { - itemId: 'claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:32c8a879-b68c-41ba-9a9e-4b844fa07890', - revision: 2, - body: { - kind: 'message', - role: 'assistant', - blocks: [ - { - type: 'text', - text: 'You prefer coffee.' - } - ] - }, - sequence: 65, - observedAt: 1792024084176, - turnScope: { - kind: 'turn', - turnItemId: - 'legacy:claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:turn-lifecycle%3A1ea7cf09-10c6-4635-93bb-8d4b16fb1c60' - } - } -] diff --git a/mobile/src/session/native-chat/input/use-mobile-native-chat-answer-send.ts b/mobile/src/session/native-chat/input/use-mobile-native-chat-answer-send.ts index 96460626c77..c14ccdac4fd 100644 --- a/mobile/src/session/native-chat/input/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/native-chat/input/use-mobile-native-chat-answer-send.ts @@ -8,7 +8,7 @@ import { import type { AskAnswerSelection } from '../../../../../src/shared/native-chat/input/native-chat-ask-answer' import type { AskPrompt } from '../../../../../src/shared/native-chat/input/native-chat-ask-types' import type { RpcClient } from '../../../transport/rpc/rpc-client-contract' -import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' +import { NATIVE_CHAT_QUESTION_STEP_MS } from '../../../../../src/shared/native-chat/input/native-chat-answer-stepping' import { openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome @@ -229,11 +229,11 @@ export function useMobileNativeChatAnswerSend(args: { return fail() } if (index < groups.length - 1) { - if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { + if (!(await wait(NATIVE_CHAT_QUESTION_STEP_MS))) { return false } // Pacing is deliberate, not transport latency — don't charge it to the budget. - deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS + deadline += NATIVE_CHAT_QUESTION_STEP_MS } } return groups.length > 0 && writeTurnsRef.current.get(handle) === turn diff --git a/mobile/src/session/native-chat/input/use-mobile-structured-agent-options.chat-return.test.ts b/mobile/src/session/native-chat/input/use-mobile-structured-agent-options.chat-return.test.ts deleted file mode 100644 index 572915f7b60..00000000000 --- a/mobile/src/session/native-chat/input/use-mobile-structured-agent-options.chat-return.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -// One phone chat view serves every chat tab, so returning to a chat must not wait on its next read. - -import { act } from 'react-test-renderer' -import { expect, it } from 'vitest' -import type { AgentSessionOptionsResult } from '../../../../../src/shared/agent-session/options/agent-session-options-result' -import { - BASE, - OPTIONS, - accepted, - currentValueOf, - deferred, - mountOptions, - optionsClient, - recordingMutate, - settle -} from './use-mobile-structured-agent-options.test-fixture' - -const COMMAND = 'compact' -const CHAT_A: AgentSessionOptionsResult = { - ...OPTIONS, - acceptsImages: true, - conversationCommands: [COMMAND], - current: { - model: 'gpt-fast', - effort: 'low', - permissionMode: 'own', - reportedPermissionMode: 'ask', - acceptedInputSources: { model: 'saved', effort: 'saved' } - } -} -const CHAT_B: AgentSessionOptionsResult = { - ...OPTIONS, - acceptsImages: false, - current: { model: 'gpt-live', effort: 'high', permissionMode: 'ask' } -} - -/** Host replies per chat; later reads for chat A wait until the test answers them. */ -function chats() { - const later: ReturnType>[] = [] - let readsOfA = 0 - let readingB = false - const client = optionsClient(() => { - if (readingB) { - return Promise.resolve(CHAT_B) - } - if (readsOfA++ === 0) { - return Promise.resolve(CHAT_A) - } - const reply = deferred() - later.push(reply) - return reply.promise - }) - const send = client.client.sendRequest - client.client.sendRequest = (method, params, options) => { - if (method === 'agentSession.options') { - readingB = JSON.stringify(params).includes('chat-b') - } - return send(method, params, options) - } - return { client, later } -} - -it("shows a chat's last answer on return, without its reports, until its new read lands", async () => { - const { client, later } = chats() - const { mutate } = recordingMutate(async () => accepted({ key: 'model', value: 'x' }, true)) - const harness = await mountOptions({ - ...BASE, - sessionId: 'chat-a', - fence: 1, - client: client.client, - mutate, - permissionOwn: true, - sendSelection: true - }) - await harness.rerender({ sessionId: 'chat-b', fence: 7 }) - expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('gpt-live') - const commitsOnB = harness.commits.length - await harness.rerender({ sessionId: 'chat-a', fence: 2 }) - expect(later).toHaveLength(1) - // From the first commit on return, not only after a later effect catches up. - for (const commit of harness.commits.slice(commitsOnB)) { - expect(currentValueOf(commit.optionSnapshot, 'model')).toBe('gpt-fast') - } - const shown = harness.current() - expect(currentValueOf(shown.optionSnapshot, 'model')).toBe('gpt-fast') - expect(currentValueOf(shown.optionSnapshot, 'effort')).toBe('low') - expect(shown.conversationCommands).toEqual([COMMAND]) - expect(shown.acceptsImages).toBe(true) - // Saved permission intent survives a restart independently of runtime reports. - expect(currentValueOf(shown.optionSnapshot, 'permissionMode')).toBe('own') - expect(shown.sendSelection()).toEqual({ selection: {} }) - - await act(async () => { - later[0].resolve({ - ...CHAT_A, - conversationCommands: [], - current: { ...CHAT_A.current, model: 'gpt-live' } - }) - }) - await settle() - const answered = harness.current() - expect(currentValueOf(answered.optionSnapshot, 'model')).toBe('gpt-live') - expect(currentValueOf(answered.optionSnapshot, 'permissionMode')).toBe('own') - expect(answered.conversationCommands).toEqual([]) - expect(answered.sendSelection()).toEqual({ selection: { model: 'gpt-live', effort: 'low' } }) - await harness.unmount() -}) diff --git a/mobile/src/session/native-chat/mobile-native-chat-diff.test.ts b/mobile/src/session/native-chat/mobile-native-chat-diff.test.ts deleted file mode 100644 index cb2936e8c51..00000000000 --- a/mobile/src/session/native-chat/mobile-native-chat-diff.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { diffFromText } from '../../../../src/shared/native-chat/tools/native-chat-diff' - -describe('diffFromText', () => { - it('caps large diffs before creating render rows', () => { - const text = Array.from( - { length: 1_000 }, - (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` - ).join('\n') - const lines = diffFromText(text) - - expect(lines).toHaveLength(120) - expect(lines?.at(-1)).toEqual({ kind: 'meta', text: '… diff truncated …' }) - }) - - it('honors a smaller caller-owned aggregate row budget', () => { - const text = Array.from( - { length: 100 }, - (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` - ).join('\n') - - expect(diffFromText(text, 20)).toHaveLength(20) - }) -}) diff --git a/mobile/src/session/native-chat/mobile-native-chat-merge.test.ts b/mobile/src/session/native-chat/mobile-native-chat-merge.test.ts deleted file mode 100644 index 3b922e822f0..00000000000 --- a/mobile/src/session/native-chat/mobile-native-chat-merge.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mergeNativeChatMessages } from '../../../../src/shared/native-chat/native-chat-merge' -import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types' - -function msg(id: string, overrides: Partial = {}): NativeChatMessage { - return { - id, - role: 'assistant', - blocks: [{ type: 'text', text: id }], - timestamp: 0, - source: 'transcript', - ...overrides - } -} - -describe('mergeNativeChatMessages', () => { - it('appends new ids in arrival order', () => { - const merged = mergeNativeChatMessages([msg('a')], [msg('b'), msg('c')]) - expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c']) - }) - - it('replaces a re-emitted id in place without reordering', () => { - const merged = mergeNativeChatMessages( - [msg('a'), msg('b'), msg('c')], - [msg('b', { blocks: [{ type: 'text', text: 'updated' }] })] - ) - expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c']) - expect(merged[1]!.blocks).toEqual([{ type: 'text', text: 'updated' }]) - }) - - it('does not let a lower-priority source overwrite a higher one', () => { - const merged = mergeNativeChatMessages( - [msg('a', { source: 'transcript', blocks: [{ type: 'text', text: 'real' }] })], - [msg('a', { source: 'scrape', blocks: [{ type: 'text', text: 'scraped' }] })] - ) - expect(merged[0]!.blocks).toEqual([{ type: 'text', text: 'real' }]) - }) - - it('returns the existing array unchanged for an empty batch', () => { - const existing = [msg('a')] - expect(mergeNativeChatMessages(existing, [])).toBe(existing) - }) - - it('does not mutate the existing array', () => { - const existing = [msg('a')] - mergeNativeChatMessages(existing, [msg('b')]) - expect(existing.map((m) => m.id)).toEqual(['a']) - }) -}) diff --git a/mobile/src/session/native-chat/mobile-native-chat-merger-oracle.test.ts b/mobile/src/session/native-chat/mobile-native-chat-merger-oracle.test.ts deleted file mode 100644 index d0f7b928f97..00000000000 --- a/mobile/src/session/native-chat/mobile-native-chat-merger-oracle.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types' -import { - applyAppend, - createNativeChatMerger, - mergeNativeChatMessages, - replaceList -} from '../../../../src/shared/native-chat/native-chat-merge' - -function msg(id: string, overrides: Partial = {}): NativeChatMessage { - return { - id, - role: 'assistant', - blocks: [{ type: 'text', text: id }], - timestamp: 0, - source: 'transcript', - ...overrides - } -} - -// Compare each adversarial prefix with the pure merger so cached indexes cannot diverge from a full rebuild. -describe('mobile stateful merger — oracle vs pure merge', () => { - const base = [msg('a'), msg('b', { source: 'hook' })] - const batches: NativeChatMessage[][] = [ - [msg('c')], - [], - [msg('b', { source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })], - [msg('b', { source: 'scrape' })], - [msg('a')], - [msg('d'), msg('e')] - ] - - it('matches the pure merge for every prefix', () => { - const merger = createNativeChatMerger() - replaceList(merger, base) - let pure = mergeNativeChatMessages(base, []) - expect(merger.list).toEqual(pure) - for (const batch of batches) { - const out = applyAppend(merger, batch) - pure = mergeNativeChatMessages(pure, batch) - expect(out).toEqual(pure) - } - }) - - it('keeps prior row identity on a pure tail append', () => { - const merger = createNativeChatMerger() - replaceList(merger, base) - const before = merger.list - const tail = msg('z') - const after = applyAppend(merger, [tail]) - expect(after).not.toBe(before) - expect(after[0]).toBe(before[0]) - expect(after[after.length - 1]).toBe(tail) - }) - - it('bounds live appends and rebuilds the cached indexes for the retained tail', () => { - const merger = createNativeChatMerger() - replaceList(merger, [msg('a'), msg('b'), msg('c')]) - - expect(applyAppend(merger, [msg('d'), msg('e')], 3).map((m) => m.id)).toEqual(['c', 'd', 'e']) - expect(merger.indexById.has('a')).toBe(false) - expect(merger.indexById.get('c')).toBe(0) - expect( - applyAppend(merger, [msg('d', { blocks: [{ type: 'text', text: 'updated' }] })], 3)[1] - ).toMatchObject({ id: 'd', blocks: [{ type: 'text', text: 'updated' }] }) - }) -}) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/native-chat/transcript/MobileNativeChatTurnStatus.test.ts deleted file mode 100644 index 52ae2dd631a..00000000000 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatTurnStatus.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { createElement, type ReactNode } from 'react' -import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AppStateStatus } from 'react-native' - -const appState = vi.hoisted(() => ({ - current: 'active', - listeners: new Set<(state: AppStateStatus) => void>() -})) - -vi.mock('react-native', async () => { - const React = await import('react') - const Text = ({ children, ...props }: { children?: ReactNode }): unknown => - React.createElement('Text', props, children) - return { - AppState: { - get currentState() { - return appState.current - }, - addEventListener: (_event: string, listener: (state: AppStateStatus) => void) => { - appState.listeners.add(listener) - return { - remove: () => { - appState.listeners.delete(listener) - } - } - } - }, - ActivityIndicator: (props: Record) => - React.createElement('ActivityIndicator', props), - Pressable: ({ children, ...props }: { children?: ReactNode }) => - React.createElement('Pressable', props, children), - Text, - View: ({ children, ...props }: { children?: ReactNode }) => - React.createElement('View', props, children), - StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } - } -}) -vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) - -import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' -import { findAllByHostType } from '../../../test-support/react-test-renderer-host-nodes' - -const labels = (node: ReactTestInstance): string[] => - findAllByHostType(node, 'Text').map((text) => String(text.children.join(''))) - -let renderer: ReactTestRenderer | null = null - -beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-09-04T00:00:00Z')) - appState.current = 'active' - appState.listeners.clear() -}) - -afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - vi.restoreAllMocks() - vi.useRealTimers() -}) - -describe('MobileNativeChatTurnStatus', () => { - function changeAppState(state: AppStateStatus) { - act(() => { - appState.current = state - for (const listener of appState.listeners) { - listener(state) - } - }) - } - function render(props: { - startedAt: number | null - workedSeconds?: number | null - verdict?: 'interruption' | 'cancellation' - expanded?: boolean - onToggleExpanded?: () => void - }): ReactTestRenderer { - act(() => { - renderer = create(createElement(MobileNativeChatTurnStatus, props)) - }) - return renderer! - } - - it('stops clock callbacks in the background and catches up immediately on resume', () => { - const tree = render({ startedAt: Date.now() }) - act(() => { - vi.advanceTimersByTime(2_000) - }) - expect(labels(tree.root)).toEqual(['Working for 2s']) - - changeAppState('background') - const clockReads = vi.spyOn(Date, 'now') - act(() => { - vi.advanceTimersByTime(60_000) - }) - expect(clockReads).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - expect(labels(tree.root)).toEqual(['Working for 2s']) - - changeAppState('active') - expect(labels(tree.root)).toEqual(['Working for 1m 2s']) - expect(vi.getTimerCount()).toBe(1) - act(() => { - vi.advanceTimersByTime(1_000) - }) - expect(labels(tree.root)).toEqual(['Working for 1m 3s']) - }) - - it('keeps the original pre-stamp epoch through background and settlement transitions', () => { - const tree = render({ startedAt: null }) - act(() => { - vi.advanceTimersByTime(3_000) - }) - expect(labels(tree.root)).toEqual(['Working for 3s']) - changeAppState('background') - act(() => { - vi.advanceTimersByTime(10_000) - }) - act(() => - tree.update(createElement(MobileNativeChatTurnStatus, { startedAt: null, workedSeconds: 8 })) - ) - changeAppState('active') - expect(labels(tree.root)).toEqual(['Worked for 8s']) - expect(vi.getTimerCount()).toBe(0) - - act(() => tree.update(createElement(MobileNativeChatTurnStatus, { startedAt: null }))) - expect(labels(tree.root)).toEqual(['Working for 13s']) - expect(vi.getTimerCount()).toBe(1) - act(() => tree.unmount()) - expect(vi.getTimerCount()).toBe(0) - expect(appState.listeners.size).toBe(0) - }) - - it('creates no clock while initially backgrounded and uses a supplied start time on resume', () => { - appState.current = 'background' - const startedAt = Date.now() - 5_000 - const tree = render({ startedAt }) - expect(vi.getTimerCount()).toBe(0) - act(() => { - vi.advanceTimersByTime(15_000) - }) - changeAppState('active') - expect(labels(tree.root)).toEqual(['Working for 20s']) - expect(vi.getTimerCount()).toBe(1) - }) -}) diff --git a/mobile/src/session/native-chat/transcript/mobile-agent-message-renderer.test-fixture.ts b/mobile/src/session/native-chat/transcript/mobile-agent-message-renderer.test-fixture.ts index 301d7c57d18..abb586ea7d8 100644 --- a/mobile/src/session/native-chat/transcript/mobile-agent-message-renderer.test-fixture.ts +++ b/mobile/src/session/native-chat/transcript/mobile-agent-message-renderer.test-fixture.ts @@ -63,15 +63,11 @@ vi.mock('lucide-react-native', () => ({ Wrench: 'Wrench', Terminal: 'Terminal' })) -const proseRenders = vi.hoisted(() => ({ count: 0 })) -export function proseRenderCount() { - return proseRenders.count -} + vi.mock('../../../components/MobileMarkdown', async () => { const React = await import('react') return { MobileMarkdown: (props: ComponentProps) => { - proseRenders.count++ return React.createElement('MobileMarkdown', props) } } diff --git a/mobile/src/session/native-chat/transcript/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/native-chat/transcript/mobile-native-chat-stream-frame.test.ts new file mode 100644 index 00000000000..59938201fe1 --- /dev/null +++ b/mobile/src/session/native-chat/transcript/mobile-native-chat-stream-frame.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { + createNativeChatMerger, + replaceList +} from '../../../../../src/shared/native-chat/native-chat-merge' +import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types' +import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript' + } +} + +describe('applyMobileNativeChatStreamFrame', () => { + it('replaces a partially overlapping replay that no longer extends the retained tail', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['old-1', 'shared', 'old-tail'].map(message)) + + // Keep hasMore true so only the contiguity check can reject the interleaved row. + const replay = [message('shared'), message('compacted-summary'), message('old-tail')] + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 9 }, + limit: 100, + replaceSnapshot: false + }) + ).toEqual({ + kind: 'messages', + messages: replay, + hasMore: true, + beforeOffset: 9, + windowReplaced: true + }) + }) + + it('replaces retained history when replay metadata says no earlier rows remain', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['removed-1', 'removed-2', 'a', 'b'].map(message)) + + const replay = [message('a'), message('b')] + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: replay, hasMore: false, beforeOffset: 0 }, + limit: 100, + replaceSnapshot: false + }) + ).toEqual({ + kind: 'messages', + messages: replay, + hasMore: false, + beforeOffset: 0, + windowReplaced: true + }) + }) +}) diff --git a/mobile/src/session/native-chat/transcript/native-chat-tool-run-words.test.ts b/mobile/src/session/native-chat/transcript/native-chat-tool-run-words.test.ts deleted file mode 100644 index 3258cc79084..00000000000 --- a/mobile/src/session/native-chat/transcript/native-chat-tool-run-words.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { SupportedUiLocale } from '../../../../../src/shared/ui-locale' -import { - joinNativeChatToolRunClauses, - nativeChatToolRunSentence -} from '../../../../../src/shared/native-chat/tools/native-chat-tool-run-sentence' -import { getMobileUiLocale } from '../../../i18n/mobile-i18n' -import { nativeChatToolRunWords } from './native-chat-tool-run-words' - -vi.mock('../../../i18n/mobile-i18n', () => ({ - getMobileUiLocale: vi.fn(() => 'en') -})) - -const joiners = { - pair: (first: string, second: string) => `${first} | ${second}`, - list: (leading: readonly string[], last: string) => [...leading, last].join(' | ') -} - -function mobileWords(locale: SupportedUiLocale) { - vi.mocked(getMobileUiLocale).mockReturnValue(locale) - return { ...nativeChatToolRunWords((_key, fallback) => fallback), ...joiners } -} - -describe('mobile tool-run sentence continuations', () => { - it('keeps German noun capitalization through the sentence formatter', () => { - const words = mobileWords('de') - expect( - nativeChatToolRunSentence( - [ - { type: 'tool-call', name: 'read', input: {} }, - { type: 'tool-call', name: 'Bash', input: { command: 'orca snapshot --page p1' } } - ], - {}, - { - ...words, - clause: (category) => (category === 'browser' ? 'Browser verwendet' : 'Datei gelesen') - } - ) - ).toBe('Datei gelesen | Browser verwendet') - }) - - it('uses Turkish casing for both dotted and dotless I in later clauses', () => { - expect(joinNativeChatToolRunClauses(['Başladı', 'İnceledi', 'Isındı'], mobileWords('tr'))).toBe( - 'Başladı | inceledi | ısındı' - ) - }) -}) diff --git a/mobile/src/session/native-chat/transcript/native-chat-turn-draw-order-scaling.test.ts b/mobile/src/session/native-chat/transcript/native-chat-turn-draw-order-scaling.test.ts deleted file mode 100644 index 33e322c37ea..00000000000 --- a/mobile/src/session/native-chat/transcript/native-chat-turn-draw-order-scaling.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { expect, it } from 'vitest' -import { nativeChatTurnDrawOrder } from '../../../../../src/shared/native-chat/transcript/native-chat-turn-grouping' -import { nativeChatTurnMembership } from '../../../../../src/shared/native-chat/transcript/native-chat-turn-membership' -import type { - AgentJournalItemBody, - AgentJournalRenderItem -} from '../../../../../src/shared/agent-session/journal/agent-session-journal-types' - -it.each([10, 100])( - 'reads turn ownership linearly for %i queued openers and earlier-turn rows', - (count) => { - const messages = [ - { id: 'first', role: 'user' as const }, - ...Array.from({ length: count }, (_, index) => ({ - id: `queued-${index}`, - role: 'user' as const - })), - ...Array.from({ length: count }, (_, index) => ({ - id: `tail-${index}`, - role: 'assistant' as const - })), - ...Array.from({ length: count }, (_, index) => ({ - id: `answer-${index}`, - role: 'assistant' as const - })) - ] - const keys = [ - 'first', - ...Array.from({ length: count }, (_, index) => `queued-${index}`), - ...Array.from({ length: count }, () => 'first'), - ...Array.from({ length: count }, (_, index) => `queued-${index}`) - ] - const openers = new Set(keys.slice(0, count + 1)) - let reads = 0 - keys.forEach((key, index) => { - Object.defineProperty(keys, index, { - get: () => { - reads += 1 - return key - } - }) - }) - const order = nativeChatTurnDrawOrder(messages, keys, openers) - const visits = reads - expect(order?.map((index) => messages[index]?.id)).toEqual([ - 'first', - ...Array.from({ length: count }, (_, index) => `tail-${index}`), - ...Array.from({ length: count }, (_, index) => [`queued-${index}`, `answer-${index}`]).flat() - ]) - const journalItems: AgentJournalRenderItem[] = [] - const append = (itemId: string, body: AgentJournalItemBody, turnItemId: string) => { - const sequence = journalItems.length + 1 - journalItems.push({ - itemId, - body, - revision: 0, - sequence, - observedAt: sequence, - turnScope: { kind: 'turn', turnItemId } - }) - } - append('first', { kind: 'message', role: 'user', blocks: [] }, 'turn-first') - append( - 'turn-first', - { kind: 'turn', turnId: 'first', state: 'completed', userItemId: 'first' }, - 'turn-first' - ) - for (let index = 0; index < count; index += 1) { - append(`queued-${index}`, { kind: 'message', role: 'user', blocks: [] }, `turn-${index}`) - } - for (let index = 0; index < count; index += 1) { - append(`tail-${index}`, { kind: 'message', role: 'assistant', blocks: [] }, 'turn-first') - } - for (let index = 0; index < count; index += 1) { - append( - `turn-${index}`, - { kind: 'turn', turnId: `t${index}`, state: 'completed', userItemId: `queued-${index}` }, - `turn-${index}` - ) - append(`answer-${index}`, { kind: 'message', role: 'assistant', blocks: [] }, `turn-${index}`) - } - const membership = nativeChatTurnMembership(messages, { items: journalItems, submissions: [] }) - expect(membership.turnKeys).toEqual(keys) - expect(membership.drawOrder).toEqual(order) - expect(visits).toBeLessThanOrEqual(messages.length * 4) - } -) diff --git a/mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts index daa4e74a737..084b090f152 100644 --- a/mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/native-chat/use-mobile-native-chat-terminal-stream.test.ts @@ -5,19 +5,13 @@ import { useMobileNativeChatTerminalStream } from './use-mobile-native-chat-term describe('useMobileNativeChatTerminalStream', () => { let renderer: ReactTestRenderer | null = null - let harnessRenderCount = 0 const subscriptionsRef = { current: new Map void>() } const subscribingRef = { current: new Set() } const leaseOnlyRef = { current: new Set() } const webReadyRef = { current: new Set(['terminal-1']) } - let subscribeOpensLeaseOnly = false const registerSubscription = (handle: string): boolean => { subscriptionsRef.current.set(handle, () => {}) - if (subscribeOpensLeaseOnly) { - leaseOnlyRef.current.add(handle) - } else { - leaseOnlyRef.current.delete(handle) - } + leaseOnlyRef.current.delete(handle) return true } const subscribe = vi.fn(registerSubscription) @@ -25,7 +19,6 @@ describe('useMobileNativeChatTerminalStream', () => { subscriptionsRef.current.delete(handle) leaseOnlyRef.current.delete(handle) }) - const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} } const notifyListedHandlesRef = { current: (_liveHandles: ReadonlySet): void => {} } const hasTabsRecoveryNeedRef = { current: (): boolean => false } @@ -33,9 +26,7 @@ describe('useMobileNativeChatTerminalStream', () => { subscriptionsRef.current = new Map([['terminal-1', () => {}]]) subscribingRef.current = new Set() leaseOnlyRef.current = new Set() - subscribeOpensLeaseOnly = false webReadyRef.current = new Set(['terminal-1']) - harnessRenderCount = 0 subscribe.mockClear() unsubscribe.mockClear() }) @@ -56,7 +47,6 @@ describe('useMobileNativeChatTerminalStream', () => { leaseReady?: boolean streamRevision?: number }): null { - harnessRenderCount += 1 const stream = useMobileNativeChatTerminalStream({ showNativeChat, activeHandle, @@ -70,7 +60,6 @@ describe('useMobileNativeChatTerminalStream', () => { subscribe, unsubscribe }) - notifyWebReadyRef.current = stream.notifyWebReady notifyListedHandlesRef.current = stream.notifyListedHandles hasTabsRecoveryNeedRef.current = stream.hasTabsRecoveryNeed return null @@ -95,244 +84,6 @@ describe('useMobileNativeChatTerminalStream', () => { }) } - // A synchronous tab switch still sees old chat coverage and opens the incoming terminal as lease-only. - function handOffToTerminalTwo(): void { - subscribeOpensLeaseOnly = true - subscribe('terminal-2') - subscribeOpensLeaseOnly = false - subscribe.mockClear() - unsubscribe.mockClear() - } - - it('trades a lease-only stream for output when a chat tab hands off to a terminal tab', async () => { - webReadyRef.current.add('terminal-2') - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - handOffToTerminalTwo() - - await act(async () => { - renderer?.update( - createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' }) - ) - }) - - expect(unsubscribe).toHaveBeenCalledWith('terminal-2') - expect(subscribe).toHaveBeenCalledWith('terminal-2') - expect(leaseOnlyRef.current.has('terminal-2')).toBe(false) - }) - - it('repairs a lease-only stream once its WebView reports ready', async () => { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - handOffToTerminalTwo() - await act(async () => { - renderer?.update( - createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' }) - ) - }) - - // A cold WebView must wait; its normal ready path skips already-live lease subscriptions. - expect(subscribe).not.toHaveBeenCalled() - - await act(async () => { - webReadyRef.current.add('terminal-2') - notifyWebReadyRef.current('terminal-2', false) - }) - - expect(unsubscribe).toHaveBeenCalledWith('terminal-2') - expect(subscribe).toHaveBeenCalledWith('terminal-2') - }) - - it.each([true, false])( - 'stops rearming a handle whose stream never comes back (covered: %s, #10681)', - async (showNativeChat) => { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat })) - }) - subscribe.mockClear() - // Dead PTYs answer subscribed/end repeatedly; rearm must stop rather than loop through host waits. - for (let revision = 1; revision <= 6; revision += 1) { - subscriptionsRef.current.delete('terminal-1') - await act(async () => { - renderer?.update( - createElement(Harness, { - showNativeChat, - leaseReady: false, - streamRevision: revision - }) - ) - }) - } - - expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3) - } - ) - - it('does not charge the rearm budget for a subscribe its own gates turned away', async () => { - try { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - subscribe.mockClear() - // Locally rejected subscribes never reached the host and must not spend real attempts. - subscribe.mockImplementation(() => false) - for (let revision = 1; revision <= 5; revision += 1) { - subscriptionsRef.current.delete('terminal-1') - await act(async () => { - renderer?.update( - createElement(Harness, { - showNativeChat: true, - leaseReady: false, - streamRevision: revision - }) - ) - }) - } - - expect(subscribe).toHaveBeenCalledTimes(5) - } finally { - subscribe.mockImplementation(registerSubscription) - } - }) - - it('charges the rearm budget for a subscribe the bridge refused before returning', async () => { - try { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: false })) - }) - subscribe.mockClear() - // A synchronous refusal ends the stream inside subscribe, so no ref ever shows it was sent. - subscribe.mockImplementation(() => true) - for (let revision = 1; revision <= 5; revision += 1) { - subscriptionsRef.current.delete('terminal-1') - await act(async () => { - renderer?.update( - createElement(Harness, { - showNativeChat: false, - leaseReady: false, - streamRevision: revision - }) - ) - }) - } - - expect(subscribe).toHaveBeenCalledTimes(3) - } finally { - subscribe.mockImplementation(registerSubscription) - } - }) - - it.each([true, false])( - 'refills the rearm budget when terminal.list reports the handle again (covered: %s, #10681)', - async (showNativeChat) => { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat })) - }) - for (let revision = 1; revision <= 5; revision += 1) { - subscriptionsRef.current.delete('terminal-1') - await act(async () => { - renderer?.update( - createElement(Harness, { - showNativeChat, - leaseReady: false, - streamRevision: revision - }) - ) - }) - } - subscribe.mockClear() - await act(async () => { - renderer?.update( - createElement(Harness, { showNativeChat, leaseReady: false, streamRevision: 6 }) - ) - }) - expect(subscribe).not.toHaveBeenCalled() - - // Still-listed dead handles must not refill on every inventory refresh. - await act(async () => { - notifyListedHandlesRef.current(new Set(['terminal-1'])) - notifyListedHandlesRef.current(new Set(['terminal-1'])) - }) - expect(subscribe).not.toHaveBeenCalled() - - await act(async () => { - notifyListedHandlesRef.current(new Set()) - notifyListedHandlesRef.current(new Set(['terminal-1'])) - }) - - expect(subscribe).toHaveBeenCalledWith('terminal-1') - } - ) - - it('does not let a dead PTY buy a new budget with its own `subscribed` ack', async () => { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - subscribe.mockClear() - // Subscribed is only half a dead-PTY reply; clearing attempts there would refill the loop forever. - for (let revision = 1; revision <= 6; revision += 1) { - await playDeadPtyRoundTrip(revision) - } - - expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3) - }) - - it('refills the budget for a rearmed stream that outlives the teardown window', async () => { - vi.useFakeTimers() - try { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - subscribe.mockClear() - for (let revision = 1; revision <= 3; revision += 1) { - await playDeadPtyRoundTrip(revision) - } - expect(subscribe).toHaveBeenCalledTimes(3) - - subscribe.mockClear() - subscriptionsRef.current.set('terminal-1', () => {}) - await act(async () => { - renderer?.update( - createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: 4 }) - ) - }) - await act(async () => { - vi.advanceTimersByTime(5_000) - }) - await playDeadPtyRoundTrip(5) - - expect(subscribe).toHaveBeenCalledWith('terminal-1') - } finally { - vi.useRealTimers() - } - }) - - it('keeps an absence marker observed before the budget ran out', async () => { - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - for (let revision = 1; revision <= 2; revision += 1) { - await playDeadPtyRoundTrip(revision) - } - // Preserve absence markers until exhaustion actually consumes them; early consumption can permanently lock a still-listed handle. - await act(async () => { - notifyListedHandlesRef.current(new Set()) - notifyListedHandlesRef.current(new Set(['terminal-1'])) - }) - await playDeadPtyRoundTrip(3) - subscribe.mockClear() - await playDeadPtyRoundTrip(4) - expect(subscribe).not.toHaveBeenCalled() - - await act(async () => { - notifyListedHandlesRef.current(new Set(['terminal-1'])) - }) - - expect(subscribe).toHaveBeenCalledWith('terminal-1') - }) - it('keeps asking until a tab snapshot replaces the exhausted handle', async () => { await act(async () => { renderer = create(createElement(Harness, { showNativeChat: true })) @@ -364,25 +115,4 @@ describe('useMobileNativeChatTerminalStream', () => { }) expect(hasTabsRecoveryNeedRef.current()).toBe(false) }) - - it('resumes a cold-start lease-only stream when WebView readiness arrives late', async () => { - webReadyRef.current.clear() - await act(async () => { - renderer = create(createElement(Harness, { showNativeChat: true })) - }) - await act(async () => { - renderer?.update(createElement(Harness, { showNativeChat: false })) - }) - - expect(unsubscribe).toHaveBeenCalledOnce() - expect(subscribe).toHaveBeenCalledOnce() - - webReadyRef.current.add('terminal-1') - await act(async () => { - notifyWebReadyRef.current('terminal-1', false) - }) - - expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') - expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') - }) }) diff --git a/mobile/src/session/tabs/mobile-session-tab-read-operations.ts b/mobile/src/session/tabs/mobile-session-tab-read-operations.ts index fe0c2f30db2..c1a9bb6ffc8 100644 --- a/mobile/src/session/tabs/mobile-session-tab-read-operations.ts +++ b/mobile/src/session/tabs/mobile-session-tab-read-operations.ts @@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rp import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' import '../../terminal/terminal-reply-schema' import { sessionForwardedReplySchema } from '../../transport/rpc/rpc-uninterpreted-reply-schemas' -import '../agent-launch/mobile-agent-detection-reply-schema' -import '../../files/session-documents/mobile-markdown-tab-reply-schema' -import '../../terminal/session/mobile-session-terminal-reply-schema' -import '../../worktree/session-worktree-record-reply-schema' -import '../native-chat/composer/mobile-native-chat-skill-reply-schema' -import '../../terminal/quick-commands/mobile-quick-command-reply-schema' -import '../../files/workspace-file-paths-reply-schema' // Leave replies raw until the controller’s generation, barrier and application-revision guards pass. export const sessionTabsListRead = bindDeferredRpcOperation( diff --git a/mobile/src/session/tabs/mobile-session-tab-write-operations.ts b/mobile/src/session/tabs/mobile-session-tab-write-operations.ts index a450d1f6a6e..856c3323fa8 100644 --- a/mobile/src/session/tabs/mobile-session-tab-write-operations.ts +++ b/mobile/src/session/tabs/mobile-session-tab-write-operations.ts @@ -1,7 +1,5 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' -import '../../files/session-documents/mobile-markdown-tab-reply-schema' -import '../../worktree/session-worktree-record-reply-schema' import '../../terminal/terminal-reply-schema' import { sessionCreatedTerminalTabSchema } from '../../terminal/session/mobile-session-terminal-reply-schema' import { sessionTabCloseReplySchema } from './mobile-session-tab-write-reply-schema' diff --git a/mobile/src/session/tabs/mobile-session-tab-write-reply-schema.ts b/mobile/src/session/tabs/mobile-session-tab-write-reply-schema.ts index 245fe3f5f02..009b0d95835 100644 --- a/mobile/src/session/tabs/mobile-session-tab-write-reply-schema.ts +++ b/mobile/src/session/tabs/mobile-session-tab-write-reply-schema.ts @@ -1,5 +1,4 @@ import * as z from 'zod' -import '../../../../src/shared/zod-salvage' // Bare legacy success and delegated closes do not prove permanent retirement. export const sessionTabCloseReplySchema = z diff --git a/mobile/src/session/use-missing-worktree-bounce.test.ts b/mobile/src/session/use-missing-worktree-bounce.test.ts index a697bbfb905..49600fd89d8 100644 --- a/mobile/src/session/use-missing-worktree-bounce.test.ts +++ b/mobile/src/session/use-missing-worktree-bounce.test.ts @@ -1,12 +1,6 @@ -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeShowResolution } from '../worktree/worktree-show-resolution' +import { describe, expect, it } from 'vitest' import { isSyntheticWorkspaceRoute } from './synthetic-workspace-route' -import { - shouldBounceMissingWorktree, - useMissingWorktreeBounce -} from './use-missing-worktree-bounce' +import { shouldBounceMissingWorktree } from './use-missing-worktree-bounce' describe('shouldBounceMissingWorktree', () => { it('bounces only a host-proven missing worktree', () => { @@ -21,60 +15,3 @@ describe('shouldBounceMissingWorktree', () => { expect(shouldBounceMissingWorktree('global-floating-terminal', 'missing')).toBe(false) }) }) - -describe('useMissingWorktreeBounce', () => { - let renderer: ReactTestRenderer | null = null - const bounce = vi.fn() - - beforeEach(() => { - bounce.mockReset() - renderer = null - }) - - function Harness(props: { worktreeId: string; resolution: WorktreeShowResolution }): null { - useMissingWorktreeBounce({ - hostId: 'host-1', - worktreeId: props.worktreeId, - resolution: props.resolution, - bounce - }) - return null - } - - function render(worktreeId: string, resolution: WorktreeShowResolution): void { - act(() => { - const element = createElement(Harness, { worktreeId, resolution }) - if (renderer) { - renderer.update(element) - } else { - renderer = create(element) - } - }) - } - - it('bounces exactly once per worktree even across re-renders', () => { - render('repo::wt', 'unknown') - expect(bounce).not.toHaveBeenCalled() - - render('repo::wt', 'missing') - expect(bounce).toHaveBeenCalledExactlyOnceWith('host-1') - - // Why: navigation lands after the render, so the pre-unmount renders must not re-fire. - render('repo::wt', 'missing') - render('repo::wt', 'unknown') - render('repo::wt', 'missing') - expect(bounce).toHaveBeenCalledTimes(1) - act(() => renderer?.unmount()) - }) - - it('re-arms for a different worktree on the reused screen', () => { - render('repo::wt-1', 'missing') - expect(bounce).toHaveBeenCalledTimes(1) - - render('repo::wt-2', 'unknown') - expect(bounce).toHaveBeenCalledTimes(1) - render('repo::wt-2', 'missing') - expect(bounce).toHaveBeenCalledTimes(2) - act(() => renderer?.unmount()) - }) -}) diff --git a/mobile/src/session/use-mobile-default-session-view-preference.test.ts b/mobile/src/session/use-mobile-default-session-view-preference.test.ts index 084164b6a40..53891f46444 100644 --- a/mobile/src/session/use-mobile-default-session-view-preference.test.ts +++ b/mobile/src/session/use-mobile-default-session-view-preference.test.ts @@ -68,52 +68,6 @@ describe('useMobileDefaultSessionViewPreference', () => { expect(saveDefaultSessionView).toHaveBeenCalledWith('chat') }) - it('submits every change immediately so shared persistence preserves event order', async () => { - const firstSave = deferred() - vi.mocked(saveDefaultSessionView) - .mockImplementationOnce(() => firstSave.promise) - .mockResolvedValue(undefined) - await mount() - - act(() => preference?.setDefaultView('chat')) - await act(async () => { - await Promise.resolve() - }) - act(() => preference?.setDefaultView('terminal')) - act(() => preference?.setDefaultView('chat')) - await act(async () => { - await Promise.resolve() - }) - expect(saveDefaultSessionView).toHaveBeenCalledTimes(3) - expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal') - expect(saveDefaultSessionView).toHaveBeenNthCalledWith(3, 'chat') - - await act(async () => { - firstSave.resolve() - await firstSave.promise - await Promise.resolve() - }) - expect(saveDefaultSessionView).toHaveBeenCalledTimes(3) - }) - - it('reloads the persisted value when the latest write fails', async () => { - vi.mocked(loadDefaultSessionView) - .mockResolvedValueOnce('terminal') - .mockResolvedValueOnce('terminal') - vi.mocked(saveDefaultSessionView).mockRejectedValue(new Error('storage unavailable')) - await mount() - - await act(async () => { - preference?.setDefaultView('chat') - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() - }) - - expect(preference?.defaultView).toBe('terminal') - expect(loadDefaultSessionView).toHaveBeenCalledTimes(2) - }) - it('does not let an older failed write roll back a newer choice', async () => { const recoveryLoad = deferred() vi.mocked(loadDefaultSessionView) @@ -140,27 +94,4 @@ describe('useMobileDefaultSessionViewPreference', () => { expect(preference?.defaultView).toBe('terminal') expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal') }) - - it('submits the latest write before the Settings route unmounts', async () => { - const firstSave = deferred() - vi.mocked(saveDefaultSessionView) - .mockImplementationOnce(() => firstSave.promise) - .mockResolvedValueOnce(undefined) - await mount() - - act(() => preference?.setDefaultView('chat')) - await act(async () => { - await Promise.resolve() - }) - act(() => preference?.setDefaultView('terminal')) - expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal') - act(() => renderer?.unmount()) - renderer = null - - await act(async () => { - firstSave.resolve() - await firstSave.promise - await Promise.resolve() - }) - }) }) diff --git a/mobile/src/session/use-mobile-session-view-mode.test.ts b/mobile/src/session/use-mobile-session-view-mode.test.ts index ad3edfa01bb..659ad7d9818 100644 --- a/mobile/src/session/use-mobile-session-view-mode.test.ts +++ b/mobile/src/session/use-mobile-session-view-mode.test.ts @@ -146,19 +146,6 @@ describe('useMobileSessionViewMode', () => { expect(controller?.ready).toBe(true) }) - it('toggles from the effective view and persists the override', async () => { - await mount({ defaultView: 'chat' }) - - await act(async () => { - controller?.toggleTabChatView('t1') - await Promise.resolve() - }) - - expect(updateSessionViewOverride).toHaveBeenLastCalledWith('h', 'w', 't1', 'terminal') - expect(controller?.isTabChatView('t1')).toBe(false) - expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(1) - }) - it('toggles a terminal-default tab into chat', async () => { await mount({ defaultView: 'terminal' }) @@ -293,41 +280,6 @@ describe('useMobileSessionViewMode', () => { }) }) - it('submits rapid mutations in event order', async () => { - await mount({ defaultView: 'terminal' }) - act(() => controller?.toggleTabChatView('t1')) - act(() => controller?.toggleTabChatView('t2')) - - expect(updateSessionViewOverride).toHaveBeenNthCalledWith(1, 'h', 'w', 't1', 'chat') - expect(updateSessionViewOverride).toHaveBeenNthCalledWith(2, 'h', 'w', 't2', 'chat') - }) - - it('reconciles the latest optimistic override when persistence fails', async () => { - vi.mocked(updateSessionViewOverride).mockRejectedValue(new Error('storage unavailable')) - vi.mocked(readSessionViewOverridesPreference) - .mockResolvedValueOnce({ - overrides: new Map([['t1', 'terminal']]), - loaded: true - }) - .mockResolvedValueOnce({ - overrides: new Map([['t1', 'terminal']]), - loaded: true - }) - await mount({ - defaultView: 'terminal', - overrides: new Map([['t1', 'terminal']]) - }) - - await act(async () => { - controller?.toggleTabChatView('t1') - await Promise.resolve() - await Promise.resolve() - }) - - expect(controller?.isTabChatView('t1')).toBe(false) - expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(2) - }) - it('does not let an older failed override roll back a newer choice', async () => { const recoveryLoad = deferred>() vi.mocked(updateSessionViewOverride) @@ -354,47 +306,6 @@ describe('useMobileSessionViewMode', () => { expect(updateSessionViewOverride).toHaveBeenNthCalledWith(2, 'h', 'w', 't1', 'terminal') }) - it('fails closed when both an override write and its recovery read fail', async () => { - vi.mocked(loadDefaultSessionView).mockResolvedValue('chat') - vi.mocked(updateSessionViewOverride).mockRejectedValue(new Error('storage unavailable')) - vi.mocked(readSessionViewOverridesPreference) - .mockResolvedValueOnce({ - overrides: new Map([['t1', 'terminal']]), - loaded: true - }) - .mockResolvedValueOnce({ overrides: new Map(), loaded: false }) - await mount({ - defaultView: 'chat', - overrides: new Map([['t1', 'terminal']]) - }) - - await act(async () => { - controller?.toggleTabChatView('t1') - await Promise.resolve() - await Promise.resolve() - }) - - expect(controller?.isTabChatView('t1')).toBe(false) - expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(2) - }) - - it('does not reload a failed override after the route unmounts', async () => { - const failedWrite = deferred() - vi.mocked(updateSessionViewOverride).mockReturnValue(failedWrite.promise) - await mount({ defaultView: 'terminal' }) - - act(() => controller?.toggleTabChatView('t1')) - act(() => renderer?.unmount()) - renderer = null - await act(async () => { - failedWrite.reject(new Error('storage unavailable')) - await Promise.resolve() - await Promise.resolve() - }) - - expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(1) - }) - it('fails closed to terminal when overrides cannot be read under a chat default', async () => { vi.mocked(loadDefaultSessionView).mockResolvedValue('chat') vi.mocked(readSessionViewOverridesPreference).mockResolvedValue({ diff --git a/mobile/src/source-control/review/mobile-review-notes-operations.ts b/mobile/src/source-control/review/mobile-review-notes-operations.ts index ed7d8688140..3a70976f334 100644 --- a/mobile/src/source-control/review/mobile-review-notes-operations.ts +++ b/mobile/src/source-control/review/mobile-review-notes-operations.ts @@ -1,11 +1,7 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' import { sessionWorktreeRecordSchema } from '../../worktree/session-worktree-record-reply-schema' -import '../../files/session-documents/mobile-markdown-tab-reply-schema' import '../../terminal/terminal-reply-schema' -import '../../terminal/session/mobile-session-terminal-reply-schema' -import '../../session/tabs/mobile-session-tab-write-reply-schema' -import '../../transport/rpc/rpc-uninterpreted-reply-schemas' import '../../terminal/quick-commands/mobile-quick-command-operations' // Preserve host refusal text for optimistic rollback; the reply's record carries the host's merged notes. diff --git a/mobile/src/terminal/quick-commands/QuickCommandRow.test.ts b/mobile/src/terminal/quick-commands/QuickCommandRow.test.ts deleted file mode 100644 index 3728dff5c4f..00000000000 --- a/mobile/src/terminal/quick-commands/QuickCommandRow.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { TerminalQuickCommand } from '../../../../src/shared/terminal/quick-commands/terminal-quick-command-types' -import { QuickCommandRow } from './QuickCommandRow' - -const clipboard = vi.hoisted(() => ({ setStringAsync: vi.fn(() => Promise.resolve(true)) })) -const haptics = vi.hoisted(() => ({ notificationAsync: vi.fn(() => Promise.resolve()) })) - -vi.mock('react-native', () => ({ - Platform: { OS: 'ios' }, - Pressable: 'Pressable', - StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, - Text: 'Text', - View: 'View' -})) - -vi.mock('lucide-react-native', () => ({ - Check: 'Check', - Copy: 'Copy', - Pencil: 'Pencil', - Play: 'Play', - Trash2: 'Trash2' -})) - -vi.mock('expo-clipboard', () => clipboard) - -vi.mock('expo-haptics', () => ({ - ...haptics, - performAndroidHapticsAsync: vi.fn(() => Promise.resolve()), - AndroidHaptics: { Reject: 'reject' }, - NotificationFeedbackType: { Error: 'error', Success: 'success' } -})) - -vi.mock('../../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' })) - -const COMMAND: TerminalQuickCommand = { - id: 'qc-1', - label: 'Run tests', - command: 'run the tests', - appendEnter: true -} - -// Require visible error feedback and haptics when clipboard writes reject; a failed write must never show Copied. -describe('the quick-command row when the pasteboard refuses the text', () => { - let renderer: ReactTestRenderer | null = null - - beforeEach(() => { - clipboard.setStringAsync.mockReset() - haptics.notificationAsync.mockReset() - haptics.notificationAsync.mockImplementation(() => Promise.resolve()) - }) - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - }) - - // Find by every copy-state label so feedback transitions cannot make the control disappear from assertions. - const COPY_LABELS = new Set([ - `Copy ${COMMAND.label}`, - 'Copied', - "Couldn't copy", - 'Nothing to copy' - ]) - - function copyButton() { - const button = renderer!.root - .findAll((node) => node.props.accessibilityRole === 'button') - .find((node) => COPY_LABELS.has(String(node.props.accessibilityLabel))) - if (button === undefined) { - throw new Error('the row has no copy button') - } - return button - } - - function rowProps() { - return { - command: COMMAND, - first: true, - onLaunch: vi.fn(), - onEdit: vi.fn(), - onDelete: vi.fn(), - disabled: false - } - } - - it('emits nothing at all when the row is gone before the refusal arrives', async () => { - // Unmount before rejection; feedback without a surviving row would buzz with no explanation. - let refuse: ((error: Error) => void) | undefined - clipboard.setStringAsync.mockImplementation( - () => - new Promise((_resolve, reject) => { - refuse = reject - }) - ) - await act(async () => { - renderer = create(createElement(QuickCommandRow, rowProps())) - }) - await act(async () => { - copyButton().props.onPress() - }) - act(() => renderer?.unmount()) - renderer = null - await act(async () => { - refuse?.(new Error('pasteboard refused')) - }) - expect(haptics.notificationAsync).not.toHaveBeenCalled() - }) -}) diff --git a/mobile/src/terminal/quick-commands/mobile-quick-command-operations.ts b/mobile/src/terminal/quick-commands/mobile-quick-command-operations.ts index c019aa7e9c2..f3f9eee7fb8 100644 --- a/mobile/src/terminal/quick-commands/mobile-quick-command-operations.ts +++ b/mobile/src/terminal/quick-commands/mobile-quick-command-operations.ts @@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rp import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' import '../terminal-reply-schema' import { terminalQuickCommandsSchema } from './mobile-quick-command-reply-schema' -import '../../session/agent-launch/mobile-agent-detection-reply-schema' -import '../../files/session-documents/mobile-markdown-tab-reply-schema' -import '../../transport/rpc/rpc-uninterpreted-reply-schemas' -import '../session/mobile-session-terminal-reply-schema' -import '../../worktree/session-worktree-record-reply-schema' -import '../../session/native-chat/composer/mobile-native-chat-skill-reply-schema' -import '../../files/workspace-file-paths-reply-schema' export const quickCommandsReader = rpcResultVariant( 'terminal-quick-commands', @@ -26,8 +19,6 @@ export const quickCommandsRead = bindDeferredRpcOperation( }) ) -import '../../session/tabs/mobile-session-tab-write-reply-schema' - export const quickCommandsWrite = bindDeferredRpcOperation( defineRpcOperation({ name: 'settings.quick-commands-write', diff --git a/mobile/src/terminal/quick-commands/mobile-quick-command-reply-schema.ts b/mobile/src/terminal/quick-commands/mobile-quick-command-reply-schema.ts index fb6a6ac66c1..2be3e0b35ca 100644 --- a/mobile/src/terminal/quick-commands/mobile-quick-command-reply-schema.ts +++ b/mobile/src/terminal/quick-commands/mobile-quick-command-reply-schema.ts @@ -1,5 +1,4 @@ import * as z from 'zod' -import '../../../../src/shared/zod-salvage' // Preserve null for unreadable canonical command lists so load/save cannot erase host commands with an invented empty list. export const terminalQuickCommandsSchema = z diff --git a/mobile/src/terminal/session/mobile-session-terminal-operations.ts b/mobile/src/terminal/session/mobile-session-terminal-operations.ts index 5de3473f24c..505d6058da8 100644 --- a/mobile/src/terminal/session/mobile-session-terminal-operations.ts +++ b/mobile/src/terminal/session/mobile-session-terminal-operations.ts @@ -2,13 +2,7 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rp import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' import { terminalComposerDraftSchema, terminalSendAcceptedSchema } from '../terminal-reply-schema' import { sessionTerminalInventorySchema } from './mobile-session-terminal-reply-schema' -import '../../session/agent-launch/mobile-agent-detection-reply-schema' -import '../../files/session-documents/mobile-markdown-tab-reply-schema' import { sessionWriteUnreadReplySchema } from '../../transport/rpc/rpc-uninterpreted-reply-schemas' -import '../../worktree/session-worktree-record-reply-schema' -import '../../session/native-chat/composer/mobile-native-chat-skill-reply-schema' -import '../quick-commands/mobile-quick-command-reply-schema' -import '../../files/workspace-file-paths-reply-schema' export const nativeChatTerminalDraftRead = bindDeferredRpcOperation( defineRpcOperation({ @@ -31,7 +25,6 @@ export const sessionTerminalListRead = bindDeferredRpcOperation( }) ) -import '../../session/tabs/mobile-session-tab-write-reply-schema' import '../quick-commands/mobile-quick-command-operations' // Keep chat writes distinct from query/accessory responders: losing a reply here means unknown delivery and forbids automatic retry. diff --git a/mobile/src/transport/rpc/rpc-uninterpreted-reply-schemas.ts b/mobile/src/transport/rpc/rpc-uninterpreted-reply-schemas.ts index e99fdbad5bb..87f8b84d347 100644 --- a/mobile/src/transport/rpc/rpc-uninterpreted-reply-schemas.ts +++ b/mobile/src/transport/rpc/rpc-uninterpreted-reply-schemas.ts @@ -1,5 +1,4 @@ import * as z from 'zod' -import '../../../../src/shared/zod-salvage' // Leave unread replies opaque; structured creates/probes require raw-envelope uncertainty checks at their callers. export const sessionLaunchUnreadReplySchema = z.unknown() diff --git a/mobile/src/worktree/session-worktree-record-operations.ts b/mobile/src/worktree/session-worktree-record-operations.ts index 5931ca217e6..b2d75f24af1 100644 --- a/mobile/src/worktree/session-worktree-record-operations.ts +++ b/mobile/src/worktree/session-worktree-record-operations.ts @@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/r import { rpcResultVariant } from '../transport/rpc/rpc-operation-result-reader' import '../terminal/terminal-reply-schema' import { sessionWorktreeRecordSchema } from './session-worktree-record-reply-schema' -import '../session/agent-launch/mobile-agent-detection-reply-schema' -import '../files/session-documents/mobile-markdown-tab-reply-schema' -import '../transport/rpc/rpc-uninterpreted-reply-schemas' -import '../terminal/session/mobile-session-terminal-reply-schema' -import '../session/native-chat/composer/mobile-native-chat-skill-reply-schema' -import '../terminal/quick-commands/mobile-quick-command-reply-schema' -import '../files/workspace-file-paths-reply-schema' // Read the whole worktree for notes and live title; unlike mutation readers, absent records need no error or wrong-host fallback. export const sessionWorktreeRecordRead = bindDeferredRpcOperation( diff --git a/src/shared/native-chat/tools/native-chat-diff.test.ts b/src/shared/native-chat/tools/native-chat-diff.test.ts new file mode 100644 index 00000000000..9f2452a8192 --- /dev/null +++ b/src/shared/native-chat/tools/native-chat-diff.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { diffFromText } from './native-chat-diff' + +describe('diffFromText', () => { + it('honors a smaller caller-owned aggregate row budget', () => { + const text = Array.from( + { length: 100 }, + (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` + ).join('\n') + + expect(diffFromText(text, 20)).toHaveLength(20) + }) +})