From 2c100efefc197a85ab0aa1e282a5e78c4dd6f24c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:35:07 -0700 Subject: [PATCH] test(ci): reuse setup across PTY connection suites (#29193) * test(ci): group PTY connection regression suites * test(ci): keep reliability gates linked to grouped PTY suites * test(ci): classify PTY mocks as test support and check live spawn state --- config/reliability-gates.jsonc | 158 +-- config/scripts/check-reliability-gates.mjs | 9 +- .../scripts/check-reliability-gates.test.mjs | 41 + ...ty-connection-agent-session-resume.spec.ts | 574 +++++++++++ ...ty-connection-agent-session-resume.test.ts | 659 ------------ ...nnection-cold-restore-agent-resume.spec.ts | 523 ++++++++++ ...nnection-cold-restore-agent-resume.test.ts | 614 ------------ ...ty-connection-cold-restore-repaint.spec.ts | 381 +++++++ ...ty-connection-cold-restore-repaint.test.ts | 469 --------- ...ection-cold-restore-resume-command.spec.ts | 397 ++++++++ ...ection-cold-restore-resume-command.test.ts | 479 --------- ...onnection-command-finished-cleanup.spec.ts | 855 ++++++++++++++++ ...onnection-command-finished-cleanup.test.ts | 920 ----------------- ...ection-completion-and-notification.test.ts | 34 + ...-connection-daemon-snapshot-replay.spec.ts | 793 +++++++++++++++ ...-connection-daemon-snapshot-replay.test.ts | 884 ----------------- ...tion-deferred-reattach-live-output.spec.ts | 460 +++++++++ ...tion-deferred-reattach-live-output.test.ts | 543 ---------- ...-connection-deliberate-sleep-guard.spec.ts | 279 ++++++ ...-connection-deliberate-sleep-guard.test.ts | 362 ------- ...onnection-foreground-agent-routing.spec.ts | 555 +++++++++++ ...onnection-foreground-agent-routing.test.ts | 626 ------------ ...nnection-foreground-agent-sampling.spec.ts | 819 +++++++++++++++ ...nnection-foreground-agent-sampling.test.ts | 890 ----------------- .../pty-connection-foreground-agent.test.ts | 25 + ...connection-foreground-process-exit.spec.ts | 117 +++ ...connection-foreground-process-exit.test.ts | 208 ---- ...on-foreground-routing-confirmation.spec.ts | 290 ++++++ ...on-foreground-routing-confirmation.test.ts | 369 ------- ...y-connection-foreground-write-path.spec.ts | 488 +++++++++ ...y-connection-foreground-write-path.test.ts | 564 ----------- .../pty-connection-fresh-spawn-guards.spec.ts | 509 ++++++++++ .../pty-connection-fresh-spawn-guards.test.ts | 590 ----------- .../pty-connection-hibernation-wake.spec.ts | 696 +++++++++++++ .../pty-connection-hibernation-wake.test.ts | 769 -------------- ...y-connection-hidden-atlas-recovery.spec.ts | 739 ++++++++++++++ ...y-connection-hidden-atlas-recovery.test.ts | 793 --------------- ...tion-hidden-backlog-reconciliation.spec.ts | 533 ++++++++++ ...tion-hidden-backlog-reconciliation.test.ts | 621 ------------ ...ty-connection-hidden-codex-queries.spec.ts | 696 +++++++++++++ ...ty-connection-hidden-codex-queries.test.ts | 761 -------------- ...ty-connection-hidden-delivery-gate.spec.ts | 627 ++++++++++++ ...ty-connection-hidden-delivery-gate.test.ts | 682 ------------- ...y-connection-hidden-output-restore.spec.ts | 581 +++++++++++ ...y-connection-hidden-output-restore.test.ts | 665 ------------- ...tion-hidden-query-snapshot-restore.spec.ts | 447 +++++++++ ...tion-hidden-query-snapshot-restore.test.ts | 518 ---------- .../pty-connection-hidden-replay.test.ts | 40 + ...ction-hidden-snapshot-live-overlap.spec.ts | 213 ++++ ...ction-hidden-snapshot-live-overlap.test.ts | 298 ------ ...ion-hidden-snapshot-resize-signals.spec.ts | 442 +++++++++ ...ion-hidden-snapshot-resize-signals.test.ts | 527 ---------- ...nection-hidden-tui-snapshot-replay.spec.ts | 177 ++++ ...nection-hidden-tui-snapshot-replay.test.ts | 264 ----- ...n-hook-completion-bell-arbitration.spec.ts | 430 ++++++++ ...n-hook-completion-bell-arbitration.test.ts | 521 ---------- ...ction-hook-completion-side-effects.spec.ts | 585 +++++++++++ ...ction-hook-completion-side-effects.test.ts | 676 ------------- .../pty-connection-input-and-repaint.test.ts | 16 + ...pty-connection-interrupt-inference.spec.ts | 749 ++++++++++++++ ...pty-connection-interrupt-inference.test.ts | 838 ---------------- ...-connection-kitty-keyboard-restore.spec.ts | 145 +++ ...-connection-kitty-keyboard-restore.test.ts | 233 ----- ...nection-main-side-effect-authority.spec.ts | 596 +++++++++++ ...nection-main-side-effect-authority.test.ts | 687 ------------- .../pty-connection-mocks.spec.ts | 140 +++ ...connection-mode-2031-subscriptions.spec.ts | 301 ++++++ ...connection-mode-2031-subscriptions.test.ts | 387 -------- ...ction-notification-settings-gating.spec.ts | 591 +++++++++++ ...ction-notification-settings-gating.test.ts | 682 ------------- ...nection-park-reveal-model-snapshot.spec.ts | 168 ++++ ...nection-park-reveal-model-snapshot.test.ts | 257 ----- .../pty-connection-pending-spawn.spec.ts | 152 +++ .../pty-connection-pending-spawn.test.ts | 241 ----- .../pty-connection-pty-exit-teardown.spec.ts | 816 +++++++++++++++ .../pty-connection-pty-exit-teardown.test.ts | 881 ----------------- ...pty-connection-reattach-mode-reset.spec.ts | 782 +++++++++++++++ ...pty-connection-reattach-mode-reset.test.ts | 868 ---------------- ...y-connection-remote-runtime-attach.spec.ts | 668 +++++++++++++ ...y-connection-remote-runtime-attach.test.ts | 755 -------------- ...y-connection-renderer-risk-repaint.spec.ts | 484 +++++++++ ...y-connection-renderer-risk-repaint.test.ts | 558 ----------- ...connection-replay-payload-handling.spec.ts | 722 ++++++++++++++ ...connection-replay-payload-handling.test.ts | 770 --------------- ...pty-connection-restart-replacement.spec.ts | 185 ++++ ...pty-connection-restart-replacement.test.ts | 275 ------ .../pty-connection-restore-and-resume.test.ts | 24 + ...ection-runtime-owner-spawn-routing.spec.ts | 397 ++++++++ ...ection-runtime-owner-spawn-routing.test.ts | 488 --------- .../pty-connection-session-liveness.spec.ts | 562 +++++++++++ .../pty-connection-session-liveness.test.ts | 648 ------------ .../pty-connection-setup-split-spawn.spec.ts | 623 ++++++++++++ .../pty-connection-setup-split-spawn.test.ts | 706 ------------- ...-connection-sleeping-resume-banner.spec.ts | 393 ++++++++ ...-connection-sleeping-resume-banner.test.ts | 482 --------- .../pty-connection-spawn-and-startup.test.ts | 24 + ...-connection-stalled-hidden-restore.spec.ts | 800 +++++++++++++++ ...-connection-stalled-hidden-restore.test.ts | 865 ---------------- ...onnection-startup-command-delivery.spec.ts | 509 ++++++++++ ...onnection-startup-command-delivery.test.ts | 586 ----------- ...-connection-startup-draft-delivery.spec.ts | 254 +++++ ...-connection-startup-draft-delivery.test.ts | 338 ------- ...-connection-task-complete-dispatch.spec.ts | 643 ++++++++++++ ...-connection-task-complete-dispatch.test.ts | 729 -------------- ...y-connection-terminal-input-gating.spec.ts | 859 ++++++++++++++++ ...y-connection-terminal-input-gating.test.ts | 934 ------------------ ...ty-connection-typed-agent-identity.spec.ts | 724 ++++++++++++++ ...ty-connection-typed-agent-identity.test.ts | 815 --------------- ...nnection-unanswered-command-finish.spec.ts | 119 +++ ...nnection-unanswered-command-finish.test.ts | 210 ---- ...pty-connection-visibility-and-exit.test.ts | 17 + ...-connection-visibility-resume-size.spec.ts | 708 +++++++++++++ ...-connection-visibility-resume-size.test.ts | 789 --------------- ...pty-connection-windows-cjk-repaint.spec.ts | 507 ++++++++++ ...pty-connection-windows-cjk-repaint.test.ts | 587 ----------- ...-connection-windows-keyboard-reset.spec.ts | 639 ++++++++++++ ...-connection-windows-keyboard-reset.test.ts | 730 -------------- 117 files changed, 27755 insertions(+), 31686 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-mocks.spec.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-restart-replacement.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-restart-replacement.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-restore-and-resume.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-session-liveness.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-session-liveness.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-startup-draft-delivery.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-startup-draft-delivery.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 94c99746545..30fbf9336e7 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -32,7 +32,7 @@ "invariant": "A literal sleep followed by && must not cause the later harness to lose hook/account preparation or hidden renderer query delivery. Preparation and typed-command hints are not live process evidence. Fresh agent startup output may request one bounded host confirmation after the initial command read window has expired.", "oracle": "All 45 configured harnesses keep hidden startup query eligibility. Delayed OpenCode 2 installs v2 hooks rather than v1, disabled harnesses do not fall back, selected managed accounts survive, and Pi/OMP/Prime preparation targets the requested harness. A captured Claude startup title at 30 seconds yields process-confirmed routing, never title-authorized routing. A title storm causes at most three additional reads; close and untrackable providers cause none.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-command-line-intent.test.ts src/shared/pi-agent-kind.test.ts src/shared/opencode-launch-command.test.ts src/main/ipc/pty/host-env/opencode-hook-installation.test.ts src/main/ipc/pty-spawn-env-terminal-basics.test.ts src/main/managed-data-accounts/launch-environment.test.ts src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts src/renderer/src/components/terminal-pane/pane-delayed-agent-startup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts --maxWorkers=2" + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-command-line-intent.test.ts src/shared/pi-agent-kind.test.ts src/shared/opencode-launch-command.test.ts src/main/ipc/pty/host-env/opencode-hook-installation.test.ts src/main/ipc/pty-spawn-env-terminal-basics.test.ts src/main/managed-data-accounts/launch-environment.test.ts src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts src/renderer/src/components/terminal-pane/pane-delayed-agent-startup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts --maxWorkers=2" ], "testFiles": [ "src/shared/agent-command-line-intent.test.ts", @@ -43,8 +43,8 @@ "src/main/managed-data-accounts/launch-environment.test.ts", "src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts", "src/renderer/src/components/terminal-pane/pane-delayed-agent-startup.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts" + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts" ], "assertionRefs": [ { @@ -63,7 +63,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts", "assertions": [ "confirms a delayed Claude from fresh startup output after the first read window expires" ] @@ -77,7 +77,8 @@ "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-command-line-intent.test.ts src/shared/pi-agent-kind.test.ts src/shared/opencode-launch-command.test.ts src/main/ipc/pty/host-env/opencode-hook-installation.test.ts src/main/ipc/pty-spawn-env-terminal-basics.test.ts src/main/managed-data-accounts/launch-environment.test.ts src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts src/renderer/src/components/terminal-pane/pane-delayed-agent-startup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts --maxWorkers=2", "result": "passed", "durationSeconds": 1.86, - "summary": "214 assertions pass across nine files, including all 45 harness classifications, actual hook files, delayed account preparation, late process routing, hidden query delivery and bounded recovery." + "summary": "214 assertions pass across nine files, including all 45 harness classifications, actual hook files, delayed account preparation, late process routing, hidden query delivery and bounded recovery.", + "currentCommand": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-command-line-intent.test.ts src/shared/pi-agent-kind.test.ts src/shared/opencode-launch-command.test.ts src/main/ipc/pty/host-env/opencode-hook-installation.test.ts src/main/ipc/pty-spawn-env-terminal-basics.test.ts src/main/managed-data-accounts/launch-environment.test.ts src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts src/renderer/src/components/terminal-pane/pane-delayed-agent-startup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts --maxWorkers=2" } ], "runtimeBudget": { @@ -1996,7 +1997,7 @@ "invariant": "Terminal status parsing preserves ordinary UTF-16 output, every valid payload in order, the last valid payload's clean-output offset, earliest BEL/ST termination, and incomplete-frame caps while searching each complete burst only forward.", "oracle": "Two 5,000-frame bursts using exclusively BEL or ST produce every expected payload and ordinary output byte with at most twice the input length in native search ranges. Mixed terminators, every split through prefixes/JSON/ST, independent parser interleaving, malformed payloads, exact pending-cap boundaries and oversized complete frames retain their previous behavior. A one-character echo performs no terminator search. Parsed output chunks are not retained in legacy regular-expression state.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-status-osc.test.ts src/shared/agent-status-osc-scan-budget.test.ts src/shared/agent-status-types.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts" + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-status-osc.test.ts src/shared/agent-status-osc-scan-budget.test.ts src/shared/agent-status-types.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts" ], "testFiles": [ "src/shared/agent-status-osc.test.ts", @@ -2031,7 +2032,8 @@ "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-status-osc.test.ts src/shared/agent-status-osc-scan-budget.test.ts src/shared/agent-status-types.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts", "result": "passed", "durationSeconds": 23.96, - "summary": "153 tests passed across eight files. Independent baseline differential review also matched 3,704 streams and 45,141 chunk results." + "summary": "153 tests passed across eight files. Independent baseline differential review also matched 3,704 streams and 45,141 chunk results.", + "currentCommand": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-status-osc.test.ts src/shared/agent-status-osc-scan-budget.test.ts src/shared/agent-status-types.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts" } ], "runtimeBudget": { @@ -2572,13 +2574,13 @@ "invariant": "Adopting a prestarted terminal preserves its existing process and paints snapshot-covered startup output once while retaining subsequent live output. Missing sequence proof or blank snapshots must not authorize dropping output.", "oracle": "Pass snapshot sequence and proven zero keyboard flags through real IPC transport projection. Deliver snapshot-covered and newer output before reattach resolves; drain replay parse callbacks and require one startup marker and the newer output. Repeat with no sequence and blank snapshot to retain unproven bytes. In Electron select a prestarted workspace, type a generated marker and compare PID and stable terminal identities before and after.", "commands": [ - "pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts", - "pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts", + "pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", + "pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts", "pnpm test src/renderer/src/components/terminal-pane/pty-connection src/renderer/src/components/terminal-pane/pty-transport" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts" + "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts" ], "assertionRefs": [ { @@ -2588,7 +2590,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", "assertions": [ "startup output covered by the snapshot is painted once", "new output remains visible", @@ -2604,7 +2606,8 @@ "command": "pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts", "result": "passed", "durationSeconds": 2.34, - "summary": "5 suites / 48 tests pass. Focused 2-suite runs independently pass 27 tests on Linux, Windows and WSL." + "summary": "5 suites / 48 tests pass. Focused 2-suite runs independently pass 27 tests on Linux, Windows and WSL.", + "currentCommand": "pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts" }, { "date": "2026-09-04", @@ -8086,7 +8089,7 @@ "invariant": "After a TUI exits or is killed, reveal, reattach, snapshot replay, or renderer remount must not deliver terminal-owned mouse or alternate-screen protocol bytes to the surviving shell. Recovery is an ordered output barrier in the daemon session data path: an OSC 133;D completing while the alternate screen is still active pauses the stream at that exact byte boundary, a fresh execution-host process inspection proves shell ownership, and on proof a mode reset is injected as in-stream output so every consumer converges by parsing the same bytes and the queued post-boundary shell output (the prompt) lands on the normal buffer. Snapshots are pure reads. Any failure — refuted proof, timeout, queue overflow, session death, disposal — flushes the queue unmodified, preserving incumbent behavior; later command or mode bytes revoke proof. Clean alternate-screen exits prove ownership asynchronously without pausing.", "oracle": "Run one fixed child-TUI journey for normal exit and cleanup-free SIGKILL. Assert renderer and host normal-buffer/non-mouse state, host snapshot terminalOwner metadata, exact PTY writes with no post-exit mouse report, unrelated-pane survival, post-boundary prompt output preserved (normal exit), ordered proof invalidation, bounded settlement and bail-out flush, one inspection per unclean episode with zero scans for ordinary output, split-escape safety at every chunk boundary, and old/new client-host fallback parity.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/terminal-shell-lifecycle-scanner.test.ts src/main/daemon/terminal-shell-recovery-barrier.test.ts src/main/daemon/session-shell-recovery.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-restore-scrollback-depth.test.ts src/main/daemon/terminal-checkpoint-serializer.test.ts src/main/providers/agent-foreground-process.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/mobile-subscribe-integration.test.ts src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts src/shared/terminal-partial-escape-tail.test.ts src/shared/terminal-partial-escape-tail.fuzz.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/terminal-shell-lifecycle-scanner.test.ts src/main/daemon/terminal-shell-recovery-barrier.test.ts src/main/daemon/session-shell-recovery.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-restore-scrollback-depth.test.ts src/main/daemon/terminal-checkpoint-serializer.test.ts src/main/providers/agent-foreground-process.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/mobile-subscribe-integration.test.ts src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts src/shared/terminal-partial-escape-tail.test.ts src/shared/terminal-partial-escape-tail.fuzz.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" @@ -8104,9 +8107,9 @@ "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/mobile-subscribe-integration.test.ts", "src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts", "src/shared/terminal-partial-escape-tail.test.ts", "src/shared/terminal-partial-escape-tail.fuzz.test.ts", @@ -9253,14 +9256,14 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-wsl-cwd-validation.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/pty/omp-shell-wrapper.node-pty.test.ts src/main/pty/omp-shell-wrapper-alias-safety.test.ts src/main/shell-wrapper-generated-file-snapshot.test.ts" ], "testFiles": [ "src/shared/terminal-startup-cwd.test.ts", "src/main/ipc/pty-spawn-cwd-fallback.test.ts", "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts", "src/main/pty/omp-shell-wrapper.node-pty.test.ts", "src/main/pty/omp-shell-wrapper-alias-safety.test.ts", "src/main/shell-wrapper-generated-file-snapshot.test.ts" @@ -9299,7 +9302,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts", "assertions": [ "fresh local IPC worktree spawns are marked with cwdFallback worktree", "startup cwd fallback metadata prints a generic in-terminal notice", @@ -9361,7 +9364,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts", "result": "passed", "durationSeconds": 6.5, - "summary": "1 test file passed, 341 tests passed; covers local IPC marking, the generic terminal fallback notice, and remote-runtime omission." + "summary": "1 test file passed, 341 tests passed; covers local IPC marking, the generic terminal fallback notice, and remote-runtime omission.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts" }, { "date": "2026-08-29", @@ -9634,7 +9638,7 @@ "oracle": "Model an active WSL project and a native-Windows Floating Workspace with WSL-only Claude and host-only Codex. Keep both detection consumers mounted, require their advertised sets to stay disjoint through refresh, require the legacy active-project inventory to remain unchanged, then independently assert the Floating launch call and PTY transport select local IPC with no WSL project runtime.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts --reporter=dot", "Headed Electron/CDP STA-4027 journey on Orca environment windows 2 with a selected Ubuntu-24.04 WSL project and local Floating Workspace" ], "testFiles": [ @@ -9645,7 +9649,7 @@ "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts", "src/renderer/src/lib/local-preflight-context.test.ts", "src/renderer/src/lib/launch-agent-in-new-tab.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts" + "src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts" ], "assertionRefs": [ { @@ -9676,7 +9680,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts", "assertions": [ "the Floating agent's actual PTY selects local IPC, carries executionHostId local, and omits the active project's WSL runtime" ] @@ -9699,7 +9703,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts --reporter=dot", "result": "passed", "durationSeconds": 20, - "summary": "The full PTY connection suite passed the independent Floating native-host transport oracle." + "summary": "The full PTY connection suite passed the independent Floating native-host transport oracle.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts --reporter=dot" }, { "date": "2026-08-12", @@ -9841,7 +9846,7 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts", "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider-spawn-session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty-pane-materialization-race.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts src/main/ipc/pty-pane-reservation-settlement.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider-spawn-session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty-pane-materialization-race.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts src/main/ipc/pty-pane-reservation-settlement.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts", "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ @@ -9855,7 +9860,7 @@ "src/main/ipc/pty-pane-reservation-settlement.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/renderer/src/lib/pane-manager/pane-fit.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts", "tests/e2e/live-background-terminal-mount-authority.spec.ts" ], @@ -9901,7 +9906,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", "assertions": [ "same-generation explicit reattach drains the authoritative snapshot before immediate live bytes and ACKs their delivery credit" ] @@ -10010,7 +10015,7 @@ "Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts src/renderer/src/lib/codex-session-restart.test.ts" ], "testFiles": [ "src/main/providers/pty-process-inspection.test.ts", @@ -10022,7 +10027,7 @@ "src/renderer/src/runtime/runtime-terminal-inspection.test.ts", "src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts", "src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts", "src/renderer/src/lib/codex-session-restart.test.ts", "src/shared/claimed-agent-pty-owner.test.ts", "src/main/daemon/daemon-pty-adapter.test.ts", @@ -10087,7 +10092,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts", "assertions": [ "completion polling uses the atomic process-inspection boundary without regressing established lifecycle behavior" ] @@ -10256,7 +10261,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts", "result": "passed", "durationSeconds": 11.96, - "summary": "Eleven focused files and 870 tests passed on the current-main candidate. The cross-boundary harness fails with the implementation reverted by dispatching process-exit from unavailable remote evidence. Direct SSH uses strict main-process inspection, daemon and relay inspection reject missing or unmapped sessions, the terminal lifecycle suite uses the atomic inspection boundary, and one stale pane cannot suppress restart notices for a separately confirmed Codex pane." + "summary": "Eleven focused files and 870 tests passed on the current-main candidate. The cross-boundary harness fails with the implementation reverted by dispatching process-exit from unavailable remote evidence. Direct SSH uses strict main-process inspection, daemon and relay inspection reject missing or unmapped sessions, the terminal lifecycle suite uses the atomic inspection boundary, and one stale pane cannot suppress restart notices for a separately confirmed Codex pane.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts src/renderer/src/lib/codex-session-restart.test.ts" }, { "date": "2026-07-21", @@ -11639,7 +11645,7 @@ "ORCA_BACKGROUND_LAUNCH=1 pnpm exec playwright test tests/e2e/paired-client-first-terminal-unopened-host-worktree.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/session-tabs-inventory-publication.test.ts src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts src/main/daemon/terminal-host-agent-session.test.ts tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", @@ -11684,8 +11690,8 @@ "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts", "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts", "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts", "src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts", "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts", "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", @@ -11901,14 +11907,14 @@ "assertions": ["consumes host facts without a raw terminal stream for paired PTYs"] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", "assertions": [ "pauses capable paired output while hidden and restores exactly on reveal", "locally gates hidden paired output when a legacy host cannot pause it" ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts", "assertions": ["restores configured paired scrollback after an ordinary park reveal"] }, { @@ -12037,7 +12043,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts", "result": "passed", "durationSeconds": 18.71, - "summary": "Seventeen focused files passed 2,044 tests with one existing skip, including the 64-PTY decorative-title bound, per-client late subscription, semantic-fact preservation, and the true no-output-pause legacy client shape." + "summary": "Seventeen focused files passed 2,044 tests with one existing skip, including the 64-PTY decorative-title bound, per-client late subscription, semantic-fact preservation, and the true no-output-pause legacy client shape.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts" }, { "date": "2026-08-02", @@ -12073,7 +12080,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts", "result": "passed", "durationSeconds": 19.54, - "summary": "Seventeen focused files passed 1,938 tests with one existing skip, including 128 active streams, retryable rejection at 129, released-slot snapshot recovery, bounded liveness probes, and balanced subscriber cleanup." + "summary": "Seventeen focused files passed 1,938 tests with one existing skip, including 128 active streams, retryable rejection at 129, released-slot snapshot recovery, bounded liveness probes, and balanced subscriber cleanup.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts" }, { "date": "2026-07-29", @@ -13008,11 +13016,11 @@ "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and direct SSH binding retention. They assert SSH state changes leave PTY bindings in place, relay identity survives, and another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination. A focused recovery oracle surfaces two distinct failures through one transport, completes authoritative replay, and requires both matching clear callbacks; pane-state tests require only the active pane's error to render, matching-pane recovery to preserve sibling and unrelated errors, dismissal to re-admit later failures, and distinct storms to retain only the newest eight messages within 24 lines and 4,000 characters.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "pnpm run build:web-from-renderer", @@ -13027,7 +13035,7 @@ "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts", "src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts", "src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts", "src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts", "src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts", "src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts", @@ -13306,7 +13314,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts", "assertions": [ "a StrictMode remount joins the pending spawn instead of starting a second one" ] @@ -13341,7 +13349,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", "durationSeconds": 3.3, - "summary": "Six direct-SSH files and 57 tests passed, including SSH tab bindings kept through connected pushes, disconnects and errors, partial-authority reconciliation, hydration, split-pane and detach contracts." + "summary": "Six direct-SSH files and 57 tests passed, including SSH tab bindings kept through connected pushes, disconnects and errors, partial-authority reconciliation, hydration, split-pane and detach contracts.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot" }, { "date": "2026-08-23", @@ -13359,7 +13368,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", "result": "passed", "durationSeconds": 15, - "summary": "Ten provider-contract files and 566 tests passed, including a shared 15-second polling/RPC deadline, no input-triggered re-polling after the bound, post-timeout snapshot recovery, listener cleanup, in-flight inventory deduplication, and replacement-state migration." + "summary": "Ten provider-contract files and 566 tests passed, including a shared 15-second polling/RPC deadline, no input-triggered re-polling after the bound, post-timeout snapshot recovery, listener cleanup, in-flight inventory deduplication, and replacement-state migration.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts" }, { "date": "2026-07-21", @@ -14205,7 +14215,7 @@ "invariant": "Shift+Enter key recognition follows the client OS, while emitted bytes follow the active application and PTY host: active Kitty keyboard protocol authorizes CSI-u on every host; otherwise Windows hosts use Esc+CR except when allowlisted Droid ownership or fresh routing-trusted Droid process evidence authorizes CSI-u, and non-Windows hosts use Esc+CR. The active SSH connection, remote-runtime PTY owner, or live local session outranks later worktree ownership changes, and missing host-platform metadata falls back to the client. Every new command and PTY replacement revokes stale agent routing until current evidence settles; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no host, agent, protocol, or ConPTY lookup.", "oracle": "Renderer tests assert exact bytes across client/PTY-host combinations, including Kitty keyboard protocol active and inactive on Windows and non-Windows hosts, and prove SSH identity, the environment encoded in an active remote-runtime PTY id, and live local-session identity outrank current worktree ownership, while unavailable platform metadata falls back to the client. A live linux-arm64 SSH PTY independently captured Esc+CR with KKP inactive and CSI-u with KKP active. Fresh Windows process evidence authorizes Droid bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", "pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts" ], @@ -14225,9 +14235,8 @@ "src/main/providers/windows-pty-job-membership.test.ts", "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts", "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts", "src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts", "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts", "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts", @@ -14372,7 +14381,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts", "assertions": [ "a still-running Droid process republishes Droid instead of being overwritten with shell-foreground evidence", "typed Droid input cannot replace live Antigravity process or pane-scoped launch identity inside another TUI", @@ -14383,7 +14392,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts", "assertions": [ "typing droid does not route CSI-u until fresh process evidence confirms Droid", "null and rejected command-start reads never promote typed Droid text to process evidence", @@ -14392,7 +14401,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts", "assertions": [ "after detach remount, pane-key launch/process identity forces a leaked nested OSC 133;D through foreground confirmation", "an identityless visible-read versus OSC 133;D race cancels stale sampling and fails closed to shell", @@ -14438,7 +14447,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", "result": "passed", "durationSeconds": 3.88, - "summary": "Twenty-six focused test files passed (489 tests) on the PR head, covering fresh-scan ordering/deduplication, exact ConPTY membership and detached-child rejection, stale PTY guards, daemon v21 launch ownership with current-process confirmation, no-OSC command/exit recovery, preserved detach shell classification, unavailable evidence, routing-neutral command generations, exact shortcut bytes, and the composed daemon-plus-tracker contract; the Windows Electron byte test remains platform-gated and is not counted as local macOS evidence." + "summary": "Twenty-six focused test files passed (489 tests) on the PR head, covering fresh-scan ordering/deduplication, exact ConPTY membership and detached-child rejection, stale PTY guards, daemon v21 launch ownership with current-process confirmation, no-OSC command/exit recovery, preserved detach shell classification, unavailable evidence, routing-neutral command generations, exact shortcut bytes, and the composed daemon-plus-tracker contract; the Windows Electron byte test remains platform-gated and is not counted as local macOS evidence.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts" } ], "runtimeBudget": { @@ -16451,14 +16461,13 @@ "invariant": "Hidden-output restore, snapshot replay, metadata-only replay, and clear-before-replay must preserve order and never overlay stale bytes on newer live terminal output; restoring a snapshot onto an already-dirty pane must yield a buffer exactly equal to the snapshot frame.", "oracle": "Apply snapshots onto adversarially dirty pane states (already on the alternate screen, stale content occupying cells the new frame leaves blank, scrollback present, wide glyphs, revived sessions with restarted PTY seq counters) and assert exact buffer equality with the snapshot frame; fault injection interleaves hidden chunks, live output, metadata-only replay, and clear-before-replay, then asserts ordered terminal buffer content, clear decisions, and replay diagnostics. Marker-presence checks are not acceptable evidence on restore paths.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts", "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts", "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts" ], @@ -16491,17 +16500,17 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", "assertions": [ "hidden Codex output is restored when the pty seq counter restarts, with or without an observed exit (#7173)" ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", "assertions": ["pending hidden restore chunks are not replayed after a terminal clear"] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts", "assertions": ["a scrolled-up viewport is preserved after hidden-backlog snapshot replay"] } ], @@ -16513,7 +16522,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts", "result": "passed", "durationSeconds": 8.4, - "summary": "1 test file(s) passed, 311 tests passed on main@1282f5c2d in a clean checkout." + "summary": "1 test file(s) passed, 311 tests passed on main@1282f5c2d in a clean checkout.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts" } ], "runtimeBudget": { @@ -16799,7 +16809,7 @@ "commands": [ "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts" ], "testFiles": [ "mobile/src/terminal/terminal-webview-query-reply.test.ts", @@ -16810,7 +16820,7 @@ "src/main/runtime/mobile-presence-lock.test.ts", "src/shared/terminal-query-reply.test.ts", "src/shared/terminal-reply-query-scan.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts" + "src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts" ], "assertionRefs": [ { @@ -16838,7 +16848,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts", "assertions": [ "desktop xterm onData replies are dropped while mobile owns the terminal driver", "desktop capability-handler replies outside onData honor the same mobile authority lock" @@ -16871,7 +16881,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts", "result": "passed", "durationSeconds": 11, - "summary": "421 renderer terminal connection tests passed, including desktop onData and capability-handler suppression while mobile owns query authority." + "summary": "421 renderer terminal connection tests passed, including desktop onData and capability-handler suppression while mobile owns query authority.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts" } ], "runtimeBudget": { @@ -16934,7 +16945,7 @@ "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-output-restore.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.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", + "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/components/terminal-pane/terminal-pty-ack-gate.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/mobile-native-chat-terminal-stream.test.ts", "pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" @@ -16948,7 +16959,7 @@ "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-output-restore.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/components/terminal-pane/terminal-pty-ack-gate.test.ts", "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts", @@ -17007,7 +17018,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", "assertions": [ "a runtime-owned hidden pane pauses output, consumes status/title/theme facts, and keeps input writable", "reveal resumes before one authoritative snapshot and exact live output with no hidden raw write, loss, or duplication", @@ -17288,7 +17299,7 @@ "invariant": "Ordinary PTY output must never clear the shared WebGL glyph atlas, including synchronized frames, hidden output, renderer-risk glyphs, and alternate-screen entry, redraw, or exit. Affected visible panes still receive pane-local refresh after parsing. Explicit paste, reveal, visibility-resume, system-resume, context-loss, and confirmed-desync recovery remain available and may reset visible renderers.", "oracle": "Parse foreground renderer-risk, synchronized, hidden, and alternate-screen PTY output and require pane-local refresh where needed with zero calls to shared reset-and-refresh recovery. Require the atlas-recovery module to expose only explicit lifecycle entry points, then verify paste and tab reveal still perform their recovery bursts. Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require heavy recovery to reset and refresh only the visible manager. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-window-focus-recovery.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-window-focus-recovery.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts", "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" ], @@ -17299,8 +17310,8 @@ "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts", "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts", "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts", "tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts" ], "assertionRefs": [ @@ -17331,11 +17342,11 @@ "assertions": ["recovers a context-lost pane on the next rendering resume"] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", "assertions": ["keeps hidden synchronized output off the global atlas recovery path"] }, { - "file": "src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts", + "file": "src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts", "assertions": [ "refreshes renderer-risk output without clearing the shared glyph atlas", "refreshes alternate-screen redraws without clearing the shared glyph atlas", @@ -17357,7 +17368,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-window-focus-recovery.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts", "result": "passed", "durationSeconds": 47, - "summary": "All 625 focused renderer contracts passed on the final head for atlas-preserving visibility, visible-only heavy recovery fanout, confirmed-desync and context-loss recovery, explicit lifecycle recovery, pane-local foreground redraws, and zero ordinary-output atlas resets." + "summary": "All 625 focused renderer contracts passed on the final head for atlas-preserving visibility, visible-only heavy recovery fanout, confirmed-desync and context-loss recovery, explicit lifecycle recovery, pane-local foreground redraws, and zero ordinary-output atlas resets.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-window-focus-recovery.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts" }, { "date": "2026-08-01", diff --git a/config/scripts/check-reliability-gates.mjs b/config/scripts/check-reliability-gates.mjs index 8cf61c5cb2b..424bd0df099 100644 --- a/config/scripts/check-reliability-gates.mjs +++ b/config/scripts/check-reliability-gates.mjs @@ -197,8 +197,13 @@ function validateEvidenceRun(gate, run, index, failures) { } if (!isNonEmptyString(run.command)) { failures.push(`${owner}.command must be a non-empty string`) - } else if (!asArray(gate.commands).includes(run.command)) { - failures.push(`${owner}.command must match one of the gate commands`) + } + // Preserve the executed command when test roots move to a new gate command. + const commandField = run.currentCommand === undefined ? 'command' : 'currentCommand' + if (commandField === 'currentCommand' && !isNonEmptyString(run.currentCommand)) { + failures.push(`${owner}.currentCommand must be a non-empty string`) + } else if (isNonEmptyString(run.command) && !asArray(gate.commands).includes(run[commandField])) { + failures.push(`${owner}.${commandField} must match one of the gate commands`) } if (!Number.isFinite(run.durationSeconds) || run.durationSeconds < 0) { failures.push(`${owner}.durationSeconds must be a non-negative number`) diff --git a/config/scripts/check-reliability-gates.test.mjs b/config/scripts/check-reliability-gates.test.mjs index b9f2293a15a..59ecb397fe3 100644 --- a/config/scripts/check-reliability-gates.test.mjs +++ b/config/scripts/check-reliability-gates.test.mjs @@ -227,6 +227,47 @@ describe('check-reliability-gates', () => { ) }) + it('preserves the executed command when evidence links to a renamed gate command', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + const manifest = JSON.parse(validManifest()) + manifest.gates[0].evidenceRuns[0].command = 'pnpm exec vitest run old-name.test.ts' + manifest.gates[0].evidenceRuns[0].currentCommand = manifest.gates[0].commands[0] + const root = makeTempRoot(JSON.stringify(manifest)) + + await expect(main(root)).resolves.toBe(0) + }) + + it.each(['', null, 42, 'pnpm exec vitest run unrelated.test.ts'])( + 'rejects an invalid current command link: %s', + async (currentCommand) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const manifest = JSON.parse(validManifest()) + manifest.gates[0].evidenceRuns[0].currentCommand = currentCommand + const root = makeTempRoot(JSON.stringify(manifest)) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('evidenceRuns[0].currentCommand') + ) + } + ) + + it.each(['', null, 42])( + 'requires the executed command even when a current command link exists: %s', + async (command) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const manifest = JSON.parse(validManifest()) + manifest.gates[0].evidenceRuns[0].command = command + manifest.gates[0].evidenceRuns[0].currentCommand = manifest.gates[0].commands[0] + const root = makeTempRoot(JSON.stringify(manifest)) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('evidenceRuns[0].command must be a non-empty string') + ) + } + ) + it('rejects protection none gates with evidence runs', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) const root = makeTempRoot( diff --git a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts new file mode 100644 index 00000000000..13dc66bc1db --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts @@ -0,0 +1,574 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { UUID_RE } from './pty-connection-test-constants' +import { + LEAF_1, + LEAF_2, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionAgentSessionResumeTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('uses sleeping-record launch config for pane cold restore after settings change', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + const launchConfig = { + agentCommand: "codex '--model' 'gpt-5' '--reasoning-effort' 'high'", + agentArgs: '--model gpt-5 --reasoning-effort high', + agentEnv: { + CODEX_PROFILE: 'captured', + ORCA_PANE_KEY: 'wrong-pane', + ORCA_TAB_ID: 'wrong-tab', + ORCA_WORKTREE_ID: 'wrong-worktree', + ORCA_WORKSPACE_ID: 'wrong-workspace' + } + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {}, + agentDefaultArgs: { codex: '--model changed' }, + agentDefaultEnv: { codex: { CODEX_PROFILE: 'changed' } } + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + launchConfig + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + const reattachArgs = transport.connect.mock.calls.find( + ([args]) => args.sessionId === 'lost-pty' + )?.[0] + const launchToken = (reattachArgs?.env as Record | undefined) + ?.ORCA_AGENT_LAUNCH_TOKEN + + expect(transport.sendInput).not.toHaveBeenCalled() + expect(launchToken).toMatch(new RegExp(`^${UUID_RE}$`)) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'lost-pty', + command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'resume' 'codex-session-1'", + env: expect.objectContaining({ + CODEX_PROFILE: 'captured', + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1', + ORCA_WORKSPACE_ID: 'wt-1', + ORCA_AGENT_LAUNCH_TOKEN: launchToken + }) + }) + ) + expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, { + agentType: 'codex', + launchToken, + tabId: 'tab-1', + leafId: LEAF_1 + }) + }) + + it('clears stale launch config when a pane consumes a non-agent startup command', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup: { command: 'echo plain-command' } }) as never + ) + await flushAsyncTicks() + + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) + }) + + it('ignores a late exit from a transport that no longer owns the pane', async () => { + const { connectPanePty } = await import('./pty-connection') + const oldTransport = createMockTransport('old-pty') + const replacementTransport = createMockTransport('new-pty') + transportFactoryQueue.push(oldTransport) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + deps.paneTransportsRef.current.set(pane.id, replacementTransport) + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + onPtyExit?.('old-pty') + + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(1, null) + expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'old-pty') + expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('old-pty') + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('clears launch config when an agent startup spawn produces no PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + const launchConfig = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + } as StoreState + + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + startup: { + command: 'codex', + launchConfig, + launchToken: 'launch-token-1', + launchAgent: 'codex' + } + }) as never + ) + try { + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith( + paneKey, + launchConfig, + { + agentType: 'codex', + launchToken: 'launch-token-1', + tabId: 'tab-1', + leafId: LEAF_1 + } + ) + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) + + const registerOrder = mockStoreState.registerAgentLaunchConfig.mock.invocationCallOrder[0] + const clearOrder = mockStoreState.clearAgentLaunchConfig.mock.invocationCallOrder[0] + expect(registerOrder).toBeDefined() + expect(clearOrder).toBeDefined() + expect(registerOrder!).toBeLessThan(clearOrder!) + } finally { + binding.dispose() + } + }) + + it('prefers live-entry launch config for pane cold restore when status survived PTY loss', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + const launchConfig = { + agentCommand: "codex '--model' 'gpt-5-mini'", + agentArgs: '--model gpt-5-mini', + agentEnv: {} + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {}, + agentDefaultArgs: { codex: '--model changed' } + }, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'finish the task', + agentType: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' } + } + }, + agentLaunchConfigByPaneKey: { + [paneKey]: { launchConfig } + }, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'lost-pty', + command: "codex '--model' 'gpt-5-mini' 'resume' 'codex-session-1'", + env: expect.objectContaining({ + ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + }) + }) + ) + expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, { + agentType: 'codex', + launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)), + tabId: 'tab-1', + leafId: LEAF_1 + }) + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + }) + + it.each(['ordinary', 'settled-worker'])( + 'restores %s through main with one resume command', + async (kind) => { + const { connectPanePty } = await import('./pty-connection') + const retainedPtyId = 'wt-1@@lost-pty' + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + : 'fresh-pty' + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: retainedPtyId }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'finish the task', + agentType: 'claude', + providerSession: { key: 'session_id', id: 'claude-session-1' } + } + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'claude-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + ...(kind === 'settled-worker' + ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } + : {}) + } + } + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: retainedPtyId } + }) as never + ) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.attach).not.toHaveBeenCalled() + const options = transport.connect.mock.calls[0]?.[0] as { + sessionId?: string + command?: string + } + expect(options.sessionId).toBe(retainedPtyId) + expect(options.command).toContain('claude-session-1') + expect(options.command?.match(/--resume/g)).toHaveLength(1) + expect(mockStoreState.tabsByWorktree['wt-1']).toHaveLength(1) + } + ) + + it('ignores stale live launch config when cold restore identity lookup rejects it', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {}, + agentDefaultArgs: { codex: '--model current' } + }, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'finish the task', + agentType: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' } + } + }, + agentLaunchConfigByPaneKey: { + [paneKey]: { + launchConfig: { + agentCommand: "codex '--model' 'stale'", + agentArgs: '--model stale', + agentEnv: {} + } + } + }, + getAgentLaunchConfigForStatusEntry: vi.fn(() => undefined), + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'older-codex-session' }, + prompt: 'older task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + launchConfig: { + agentCommand: "codex '--model' 'sleeping-stale'", + agentArgs: '--model sleeping-stale', + agentEnv: { CODEX_PROFILE: 'sleeping-stale' } + } + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(mockStoreState.getAgentLaunchConfigForStatusEntry).toHaveBeenCalledWith( + expect.objectContaining({ paneKey, agentType: 'codex' }) + ) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'lost-pty', + command: "codex '--model' 'current' 'resume' 'codex-session-1'" + }) + ) + expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith( + paneKey, + expect.objectContaining({ + agentArgs: '--model current' + }), + expect.objectContaining({ + agentType: 'codex', + tabId: 'tab-1', + leafId: LEAF_1 + }) + ) + }) + + it('shows the restored banner when a sleeping resume falls back to a fresh shell', async () => { + const { connectPanePty } = await import('./pty-connection') + const staleSessionId = 'wt-1@@stale-session' + const transport = createMockTransport() + transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { + if (opts.sessionId) { + return undefined + } + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('fresh-pty') + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_2) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: staleSessionId }] + }, + ptyIdsByTabId: { + 'tab-1': [staleSessionId] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: staleSessionId } + } + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: staleSessionId } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(10) + + expect(transport.connect).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ sessionId: staleSessionId }) + ) + expect(transport.connect).toHaveBeenNthCalledWith( + 2, + expect.not.objectContaining({ sessionId: expect.any(String) }) + ) + expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', staleSessionId) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + expect.stringContaining('--- session restored ---'), + expect.any(Function) + ) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'restored') + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts deleted file mode 100644 index c0305769c40..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts +++ /dev/null @@ -1,659 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { UUID_RE } from './pty-connection-test-constants' -import { - LEAF_1, - LEAF_2, - createMockTransport, - createPane, - createManager -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import type { MockTransport } from './pty-connection-test-pane-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('uses sleeping-record launch config for pane cold restore after settings change', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('fresh-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: 'fresh-pty', - coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } - } - } - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - const launchConfig = { - agentCommand: "codex '--model' 'gpt-5' '--reasoning-effort' 'high'", - agentArgs: '--model gpt-5 --reasoning-effort high', - agentEnv: { - CODEX_PROFILE: 'captured', - ORCA_PANE_KEY: 'wrong-pane', - ORCA_TAB_ID: 'wrong-tab', - ORCA_WORKTREE_ID: 'wrong-worktree', - ORCA_WORKSPACE_ID: 'wrong-workspace' - } - } - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {}, - agentDefaultArgs: { codex: '--model changed' }, - agentDefaultEnv: { codex: { CODEX_PROFILE: 'changed' } } - }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1, - launchConfig - } - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - const reattachArgs = transport.connect.mock.calls.find( - ([args]) => args.sessionId === 'lost-pty' - )?.[0] - const launchToken = (reattachArgs?.env as Record | undefined) - ?.ORCA_AGENT_LAUNCH_TOKEN - - expect(transport.sendInput).not.toHaveBeenCalled() - expect(launchToken).toMatch(new RegExp(`^${UUID_RE}$`)) - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: 'lost-pty', - command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'resume' 'codex-session-1'", - env: expect.objectContaining({ - CODEX_PROFILE: 'captured', - ORCA_PANE_KEY: paneKey, - ORCA_TAB_ID: 'tab-1', - ORCA_WORKTREE_ID: 'wt-1', - ORCA_WORKSPACE_ID: 'wt-1', - ORCA_AGENT_LAUNCH_TOKEN: launchToken - }) - }) - ) - expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, { - agentType: 'codex', - launchToken, - tabId: 'tab-1', - leafId: LEAF_1 - }) - }) - - it('clears stale launch config when a pane consumes a non-agent startup command', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ startup: { command: 'echo plain-command' } }) as never - ) - await flushAsyncTicks() - - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) - }) - - it('ignores a late exit from a transport that no longer owns the pane', async () => { - const { connectPanePty } = await import('./pty-connection') - const oldTransport = createMockTransport('old-pty') - const replacementTransport = createMockTransport('new-pty') - transportFactoryQueue.push(oldTransport) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - deps.paneTransportsRef.current.set(pane.id, replacementTransport) - - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - onPtyExit?.('old-pty') - - expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(1, null) - expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'old-pty') - expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('old-pty') - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('clears launch config when an agent startup spawn produces no PTY', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - const launchConfig = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - ptyIdsByTabId: { 'tab-1': [] }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_1 }, - activeLeafId: LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - } as StoreState - - const binding = connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ - startup: { - command: 'codex', - launchConfig, - launchToken: 'launch-token-1', - launchAgent: 'codex' - } - }) as never - ) - try { - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, { - agentType: 'codex', - launchToken: 'launch-token-1', - tabId: 'tab-1', - leafId: LEAF_1 - }) - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) - - const registerOrder = mockStoreState.registerAgentLaunchConfig.mock.invocationCallOrder[0] - const clearOrder = mockStoreState.clearAgentLaunchConfig.mock.invocationCallOrder[0] - expect(registerOrder).toBeDefined() - expect(clearOrder).toBeDefined() - expect(registerOrder!).toBeLessThan(clearOrder!) - } finally { - binding.dispose() - } - }) - - it('prefers live-entry launch config for pane cold restore when status survived PTY loss', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('fresh-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: 'fresh-pty', - coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } - } - } - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - const launchConfig = { - agentCommand: "codex '--model' 'gpt-5-mini'", - agentArgs: '--model gpt-5-mini', - agentEnv: {} - } - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {}, - agentDefaultArgs: { codex: '--model changed' } - }, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - state: 'working', - prompt: 'finish the task', - agentType: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' } - } - }, - agentLaunchConfigByPaneKey: { - [paneKey]: { launchConfig } - }, - sleepingAgentSessionsByPaneKey: {} - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(transport.sendInput).not.toHaveBeenCalled() - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: 'lost-pty', - command: "codex '--model' 'gpt-5-mini' 'resume' 'codex-session-1'", - env: expect.objectContaining({ - ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) - }) - }) - ) - expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, { - agentType: 'codex', - launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)), - tabId: 'tab-1', - leafId: LEAF_1 - }) - expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() - }) - - it.each(['ordinary', 'settled-worker'])( - 'restores %s through main with one resume command', - async (kind) => { - const { connectPanePty } = await import('./pty-connection') - const retainedPtyId = 'wt-1@@lost-pty' - const transport = createMockTransport() - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => - sessionId - ? { - id: 'fresh-pty', - coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } - } - : 'fresh-pty' - ) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: retainedPtyId }] - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {} - }, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - state: 'working', - prompt: 'finish the task', - agentType: 'claude', - providerSession: { key: 'session_id', id: 'claude-session-1' } - } - }, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'claude-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1, - ...(kind === 'settled-worker' - ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } - : {}) - } - } - } as StoreState - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: retainedPtyId } - }) as never - ) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(transport.connect).toHaveBeenCalledTimes(1) - expect(transport.attach).not.toHaveBeenCalled() - const options = transport.connect.mock.calls[0]?.[0] as { - sessionId?: string - command?: string - } - expect(options.sessionId).toBe(retainedPtyId) - expect(options.command).toContain('claude-session-1') - expect(options.command?.match(/--resume/g)).toHaveLength(1) - expect(mockStoreState.tabsByWorktree['wt-1']).toHaveLength(1) - } - ) - - it('ignores stale live launch config when cold restore identity lookup rejects it', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('fresh-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: 'fresh-pty', - coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } - } - } - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {}, - agentDefaultArgs: { codex: '--model current' } - }, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - state: 'working', - prompt: 'finish the task', - agentType: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' } - } - }, - agentLaunchConfigByPaneKey: { - [paneKey]: { - launchConfig: { - agentCommand: "codex '--model' 'stale'", - agentArgs: '--model stale', - agentEnv: {} - } - } - }, - getAgentLaunchConfigForStatusEntry: vi.fn(() => undefined), - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'older-codex-session' }, - prompt: 'older task', - state: 'working', - capturedAt: 1, - updatedAt: 1, - launchConfig: { - agentCommand: "codex '--model' 'sleeping-stale'", - agentArgs: '--model sleeping-stale', - agentEnv: { CODEX_PROFILE: 'sleeping-stale' } - } - } - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(mockStoreState.getAgentLaunchConfigForStatusEntry).toHaveBeenCalledWith( - expect.objectContaining({ paneKey, agentType: 'codex' }) - ) - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: 'lost-pty', - command: "codex '--model' 'current' 'resume' 'codex-session-1'" - }) - ) - expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith( - paneKey, - expect.objectContaining({ - agentArgs: '--model current' - }), - expect.objectContaining({ - agentType: 'codex', - tabId: 'tab-1', - leafId: LEAF_1 - }) - ) - }) - - it('shows the restored banner when a sleeping resume falls back to a fresh shell', async () => { - const { connectPanePty } = await import('./pty-connection') - const staleSessionId = 'wt-1@@stale-session' - const transport = createMockTransport() - transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { - if (opts.sessionId) { - return undefined - } - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - onPtySpawn?.('fresh-pty') - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_2) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: staleSessionId }] - }, - ptyIdsByTabId: { - 'tab-1': [staleSessionId] - }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_2 }, - activeLeafId: LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_2]: staleSessionId } - } - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {} - }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { [LEAF_2]: staleSessionId } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(10) - - expect(transport.connect).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ sessionId: staleSessionId }) - ) - expect(transport.connect).toHaveBeenNthCalledWith( - 2, - expect.not.objectContaining({ sessionId: expect.any(String) }) - ) - expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', staleSessionId) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - expect.stringContaining('--- session restored ---'), - expect.any(Function) - ) - expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) - expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'restored') - expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts new file mode 100644 index 00000000000..7849c170a4b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts @@ -0,0 +1,523 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles' +import { flushAsyncTicks } from './pty-connection-test-async' +import { UUID_RE } from './pty-connection-test-constants' +import { + LEAF_1, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionColdRestoreAgentResumeTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + it('resumes the provider agent session when daemon reattach cold-restores a fresh shell', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: { + [paneKey]: { + state: 'working', + prompt: 'finish the task', + agentType: 'codex', + paneKey, + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + providerSession: { + key: 'session_id', + id: 'codex-session-1', + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl' + } + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}cold-payload`, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + expect.stringContaining('--- session restored ---'), + expect.any(Function) + ) + expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'lost-pty', + command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", + resumeProviderSession: { + key: 'session_id', + id: 'codex-session-1', + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl' + }, + env: expect.objectContaining({ + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1', + ORCA_WORKSPACE_ID: 'wt-1', + ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + }) + }) + ) + }) + + it('uses WSL quoting for cold-restored agent resume in Windows-path WSL projects', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: 'C:\\tmp\\wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {}, + localWindowsRuntimeDefault: { kind: 'windows-host' } + }, + projects: [ + { + id: 'repo1', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ], + worktreesByRepo: { + repo1: [ + { + id: 'wt-1', + repoId: 'repo1', + path: 'C:\\tmp\\wt-1', + displayName: 'feat/notis' + } + ] + }, + agentStatusByPaneKey: { + [paneKey]: { + state: 'working', + prompt: 'finish the task', + agentType: 'codex', + paneKey, + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + providerSession: { key: 'session_id', id: "codex-session-1's" } + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}cold-payload`, + expect.any(Function) + ) + expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'lost-pty', + command: `codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'"'"'s'`, + env: expect.objectContaining({ + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1', + ORCA_WORKSPACE_ID: 'wt-1', + ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + }) + }) + ) + }) + + it('resumes from the quit-captured sleeping record when cold-restoring after an app restart', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + const transcriptPath = + '\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-codex-session-1.jsonl' + // Why: after restart agentStatusByPaneKey is empty — the persisted sleeping record is the only provider session id source (#5232). + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { + key: 'session_id', + id: 'codex-session-1', + transcriptPath + }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}cold-payload`, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + expect.stringContaining('--- session restored ---'), + expect.any(Function) + ) + const writeCalls = pane.terminal.write.mock.calls.map(([data]) => data) + expect(writeCalls.findIndex((data) => data.includes('--- session restored ---'))).toBe(-1) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'restored') + expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'lost-pty', + command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", + resumeProviderSession: { + key: 'session_id', + id: 'codex-session-1', + transcriptPath + }, + env: expect.objectContaining({ + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1', + ORCA_WORKSPACE_ID: 'wt-1', + ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + }) + }) + ) + // Why: consuming the record prevents a later worktree activation from launching a duplicate resume tab. + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) + }) + + it('marks the pane as freshly started when main declined an unverifiable resume', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + // Why: main dropped `resume ` because it could not verify which Codex + // account owns the rollout, so this PTY is a new session, not a restore. + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' }, + agentResumeUnavailable: true as const + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'resume-unavailable') + }) + + it('resumes from an unambiguous legacy sleeping record when cold-restoring a preserved pane', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const legacyPaneKey = 'tab-1:2' + const duplicateLegacyPaneKey = 'tab-1:3' + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [legacyPaneKey]: { + paneKey: legacyPaneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + }, + [duplicateLegacyPaneKey]: { + paneKey: duplicateLegacyPaneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 2, + updatedAt: 2 + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}cold-payload`, + expect.any(Function) + ) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'restored') + expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'lost-pty', + command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'" + }) + ) + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(legacyPaneKey) + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(duplicateLegacyPaneKey) + expect(mockStoreState.sleepingAgentSessionsByPaneKey[legacyPaneKey]).toBeUndefined() + expect(mockStoreState.sleepingAgentSessionsByPaneKey[duplicateLegacyPaneKey]).toBeUndefined() + }) + + it('does not choose a non-exact legacy record when same-tab provider sessions differ', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const firstLegacyPaneKey = 'tab-1:2' + const secondLegacyPaneKey = 'tab-1:3' + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [firstLegacyPaneKey]: { + paneKey: firstLegacyPaneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + }, + [secondLegacyPaneKey]: { + paneKey: secondLegacyPaneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-2' }, + prompt: 'finish another task', + state: 'working', + capturedAt: 2, + updatedAt: 2 + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}cold-payload`, + expect.any(Function) + ) + expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() + expect(transport.connect).not.toHaveBeenCalledWith( + expect.objectContaining({ command: expect.stringContaining('resume') }) + ) + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts deleted file mode 100644 index 70157e83505..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts +++ /dev/null @@ -1,614 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles' -import { flushAsyncTicks } from './pty-connection-test-async' -import { UUID_RE } from './pty-connection-test-constants' -import { - LEAF_1, - createMockTransport, - createPane, - createManager -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import type { MockTransport } from './pty-connection-test-pane-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - it('resumes the provider agent session when daemon reattach cold-restores a fresh shell', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('fresh-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: 'fresh-pty', - coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } - } - } - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {} - }, - agentStatusByPaneKey: { - [paneKey]: { - state: 'working', - prompt: 'finish the task', - agentType: 'codex', - paneKey, - updatedAt: 1, - stateStartedAt: 1, - stateHistory: [], - providerSession: { - key: 'session_id', - id: 'codex-session-1', - transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl' - } - } - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_GRAPHIC_RENDITION}cold-payload`, - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - expect.stringContaining('--- session restored ---'), - expect.any(Function) - ) - expect(transport.sendInput).not.toHaveBeenCalled() - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: 'lost-pty', - command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", - resumeProviderSession: { - key: 'session_id', - id: 'codex-session-1', - transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl' - }, - env: expect.objectContaining({ - ORCA_PANE_KEY: paneKey, - ORCA_TAB_ID: 'tab-1', - ORCA_WORKTREE_ID: 'wt-1', - ORCA_WORKSPACE_ID: 'wt-1', - ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) - }) - }) - ) - }) - - it('uses WSL quoting for cold-restored agent resume in Windows-path WSL projects', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('fresh-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: 'fresh-pty', - coldRestore: { scrollback: 'cold-payload', cwd: 'C:\\tmp\\wt-1' } - } - } - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {}, - localWindowsRuntimeDefault: { kind: 'windows-host' } - }, - projects: [ - { - id: 'repo1', - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } - ], - worktreesByRepo: { - repo1: [ - { - id: 'wt-1', - repoId: 'repo1', - path: 'C:\\tmp\\wt-1', - displayName: 'feat/notis' - } - ] - }, - agentStatusByPaneKey: { - [paneKey]: { - state: 'working', - prompt: 'finish the task', - agentType: 'codex', - paneKey, - updatedAt: 1, - stateStartedAt: 1, - stateHistory: [], - providerSession: { key: 'session_id', id: "codex-session-1's" } - } - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_GRAPHIC_RENDITION}cold-payload`, - expect.any(Function) - ) - expect(transport.sendInput).not.toHaveBeenCalled() - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: 'lost-pty', - command: `codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'"'"'s'`, - env: expect.objectContaining({ - ORCA_PANE_KEY: paneKey, - ORCA_TAB_ID: 'tab-1', - ORCA_WORKTREE_ID: 'wt-1', - ORCA_WORKSPACE_ID: 'wt-1', - ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) - }) - }) - ) - }) - - it('resumes from the quit-captured sleeping record when cold-restoring after an app restart', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('fresh-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: 'fresh-pty', - coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } - } - } - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - const transcriptPath = - '\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-codex-session-1.jsonl' - // Why: after restart agentStatusByPaneKey is empty — the persisted sleeping record is the only provider session id source (#5232). - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {} - }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { - key: 'session_id', - id: 'codex-session-1', - transcriptPath - }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_GRAPHIC_RENDITION}cold-payload`, - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - expect.stringContaining('--- session restored ---'), - expect.any(Function) - ) - const writeCalls = pane.terminal.write.mock.calls.map(([data]) => data) - expect(writeCalls.findIndex((data) => data.includes('--- session restored ---'))).toBe(-1) - expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) - expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'restored') - expect(transport.sendInput).not.toHaveBeenCalled() - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: 'lost-pty', - command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", - resumeProviderSession: { - key: 'session_id', - id: 'codex-session-1', - transcriptPath - }, - env: expect.objectContaining({ - ORCA_PANE_KEY: paneKey, - ORCA_TAB_ID: 'tab-1', - ORCA_WORKTREE_ID: 'wt-1', - ORCA_WORKSPACE_ID: 'wt-1', - ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) - }) - }) - ) - // Why: consuming the record prevents a later worktree activation from launching a duplicate resume tab. - expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) - }) - - it('marks the pane as freshly started when main declined an unverifiable resume', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('fresh-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - // Why: main dropped `resume ` because it could not verify which Codex - // account owns the rollout, so this PTY is a new session, not a restore. - return { - id: 'fresh-pty', - coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' }, - agentResumeUnavailable: true as const - } - } - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {} - }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) - expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'resume-unavailable') - }) - - it('resumes from an unambiguous legacy sleeping record when cold-restoring a preserved pane', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('fresh-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: 'fresh-pty', - coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } - } - } - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - const legacyPaneKey = 'tab-1:2' - const duplicateLegacyPaneKey = 'tab-1:3' - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {} - }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - [legacyPaneKey]: { - paneKey: legacyPaneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1 - }, - [duplicateLegacyPaneKey]: { - paneKey: duplicateLegacyPaneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 2, - updatedAt: 2 - } - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_GRAPHIC_RENDITION}cold-payload`, - expect.any(Function) - ) - expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'restored') - expect(transport.sendInput).not.toHaveBeenCalled() - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: 'lost-pty', - command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'" - }) - ) - expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(legacyPaneKey) - expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(duplicateLegacyPaneKey) - expect(mockStoreState.sleepingAgentSessionsByPaneKey[legacyPaneKey]).toBeUndefined() - expect(mockStoreState.sleepingAgentSessionsByPaneKey[duplicateLegacyPaneKey]).toBeUndefined() - }) - - it('does not choose a non-exact legacy record when same-tab provider sessions differ', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('fresh-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: 'fresh-pty', - coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } - } - } - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - const firstLegacyPaneKey = 'tab-1:2' - const secondLegacyPaneKey = 'tab-1:3' - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {} - }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - [firstLegacyPaneKey]: { - paneKey: firstLegacyPaneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1 - }, - [secondLegacyPaneKey]: { - paneKey: secondLegacyPaneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-2' }, - prompt: 'finish another task', - state: 'working', - capturedAt: 2, - updatedAt: 2 - } - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_GRAPHIC_RENDITION}cold-payload`, - expect.any(Function) - ) - expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() - expect(transport.connect).not.toHaveBeenCalledWith( - expect.objectContaining({ command: expect.stringContaining('resume') }) - ) - expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.spec.ts new file mode 100644 index 00000000000..e5b4a543fda --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.spec.ts @@ -0,0 +1,381 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + POST_REPLAY_MODE_RESET, + RESET_GRAPHIC_RENDITION, + RELEASE_SYNCHRONIZED_OUTPUT +} from '../../../../shared/terminal-mode-reset-profiles' +import { Terminal } from '@xterm/headless' +import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' +import { + flushAsyncTicks, + writeHeadlessTerminal, + renderHeadlessTerminalState +} from './pty-connection-test-async' +import { NORMAL_BUFFER_PROLOGUE } from './pty-connection-test-constants' +import { + LEAF_1, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import type { ConnectCallbacks, MockTransport } from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionColdRestoreRepaintTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + it('paints only the daemon snapshot when reattach result includes both snapshot and replay', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'snapshot-payload', + replay: 'replay-payload' + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}snapshot-payload`, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith('replay-payload', expect.any(Function)) + }) + + it('paints only relay replay when reattach result has replay and coldRestore but no snapshot', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + replay: 'replay-payload', + coldRestore: { scrollback: 'cold-payload' } + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}replay-payload`, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith('cold-payload', expect.any(Function)) + // Why: the replay branch supersedes cold-restore but must still ack, or the daemon redelivers the cold-restore payload next reattach. + expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('tab-pty') + }) + + it('blanks restored scrollback before fresh shell output', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + const written: string[] = [] + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacks.onData?.('PS >') + return 'fresh-pty' + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + } as StoreState + + const pane = createPane(1) + pane.terminal.rows = 4 + pane.terminal.cols = 20 + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + written.push(data) + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + restoredViewportBlankingPanesRef: { current: new Set([1]) } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const blankViewport = buildFreshShellViewportBlankingSequence(4) + expect(written).toContain(blankViewport) + expect(written.indexOf(blankViewport)).toBeLessThan(written.indexOf('PS >')) + + const rendered = await renderHeadlessTerminalState( + ['old TUI row with a long tail\r\nold TUI row two', blankViewport, 'PS >'], + 20, + 4 + ) + expect(rendered.baseY).toBeGreaterThan(0) + expect(rendered.allLines.some((line) => line.includes('old TUI row'))).toBe(true) + expect(rendered.visibleLines).toEqual(['PS >', '', '', '']) + }) + + it('cold-restores at the recovered grid and clears only the dirty viewport before blanking', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + const written: string[] = [] + const operations: ( + | { kind: 'write'; data: string } + | { kind: 'resize'; cols: number; rows: number } + )[] = [] + const destinationCols = 10 + const destinationRows = 5 + const recoveredCols = 20 + const recoveredRows = 3 + const coldScrollback = '\x1b[1;1HCOLD\x1b[1;15HEND\r\nCOLD_SOURCE_ROW_02' + const groundedColdScrollback = `${RESET_GRAPHIC_RENDITION}${coldScrollback}` + const viewportClear = `${RELEASE_SYNCHRONIZED_OUTPUT}${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[H` + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { + scrollback: coldScrollback, + cwd: '/tmp/wt-1', + cols: recoveredCols, + rows: recoveredRows + } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + } + } as StoreState + + const pane = createPane(1) + pane.terminal.rows = destinationRows + pane.terminal.cols = destinationCols + let sawViewportClear = false + const preResizeBarrier = { release: null as (() => void) | null } + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + written.push(data) + operations.push({ kind: 'write', data }) + if (data === viewportClear) { + sawViewportClear = true + } else if (sawViewportClear && data === '' && preResizeBarrier.release === null) { + preResizeBarrier.release = callback ?? (() => {}) + return + } + callback?.() + }) + pane.terminal.resize = vi.fn((cols: number, rows: number) => { + operations.push({ kind: 'resize', cols, rows }) + pane.terminal.cols = cols + pane.terminal.rows = rows + }) + const manager = createManager(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ + cols: destinationCols, + rows: destinationRows + })) + pane.fitAddon.fit = vi.fn(() => { + pane.terminal.resize(destinationCols, destinationRows) + }) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + expect(preResizeBarrier.release).not.toBeNull() + expect(pane.terminal.resize).not.toHaveBeenCalledWith(recoveredCols, recoveredRows) + expect(written).not.toContain(coldScrollback) + + preResizeBarrier.release?.() + await flushAsyncTicks(20) + + const blankViewport = buildFreshShellViewportBlankingSequence(destinationRows) + expect(pane.terminal.resize).toHaveBeenCalledWith(recoveredCols, recoveredRows) + expect(transport.resize).not.toHaveBeenCalledWith(recoveredCols, recoveredRows) + expect(transport.resize).toHaveBeenCalledWith(destinationCols, destinationRows) + expect(transport.resize).toHaveBeenLastCalledWith(destinationCols, destinationRows, { + claim: true + }) + expect(written).toContain(viewportClear) + expect(written).not.toContain(NORMAL_BUFFER_PROLOGUE) + expect(written).toEqual( + expect.arrayContaining([groundedColdScrollback, POST_REPLAY_MODE_RESET, blankViewport]) + ) + expect(written.indexOf(viewportClear)).toBeLessThan(written.indexOf(groundedColdScrollback)) + expect(written.indexOf(groundedColdScrollback)).toBeLessThan(written.indexOf(blankViewport)) + const viewportClearOperation = operations.findIndex( + (operation) => operation.kind === 'write' && operation.data === viewportClear + ) + const recoveredResizeOperation = operations.findIndex( + (operation) => + operation.kind === 'resize' && + operation.cols === recoveredCols && + operation.rows === recoveredRows + ) + expect(viewportClearOperation).toBeLessThan(recoveredResizeOperation) + + // Model the real op order: the old path appended a 20-col snapshot onto a dirty 10-col xterm, wrapping rows at the wrong grid. + const rendered = new Terminal({ + cols: destinationCols, + rows: destinationRows, + allowProposedApi: true + }) + try { + await writeHeadlessTerminal( + rendered, + 'KEEP_1\r\nKEEP_2\r\nOLD_ROW_1\r\nOLD_ROW_2\r\nOLD_ROW_3\r\nOLD_ROW_4\r\nOLD_ROW_5' + ) + await writeHeadlessTerminal(rendered, '\x1b[44m') + let replayedAtRecoveredGrid = false + let sourceGridLines: string[] = [] + for (const operation of operations) { + if (operation.kind === 'resize') { + if ( + replayedAtRecoveredGrid && + operation.cols === destinationCols && + operation.rows === destinationRows && + sourceGridLines.length === 0 + ) { + sourceGridLines = Array.from( + { length: rendered.buffer.active.length }, + (_, lineIndex) => + rendered.buffer.active.getLine(lineIndex)?.translateToString(true) ?? '' + ) + } + rendered.resize(operation.cols, operation.rows) + if (operation.cols === recoveredCols && operation.rows === recoveredRows) { + replayedAtRecoveredGrid = true + } + } else { + await writeHeadlessTerminal(rendered, operation.data) + if (operation.data === viewportClear) { + expect(rendered.buffer.active.getLine(0)?.getCell(0)?.getBgColor()).toBe(-1) + } + } + } + if (sourceGridLines.length === 0) { + sourceGridLines = Array.from( + { length: rendered.buffer.active.length }, + (_, lineIndex) => + rendered.buffer.active.getLine(lineIndex)?.translateToString(true) ?? '' + ) + } + expect(sourceGridLines).toContain('COLD END') + if (rendered.cols !== destinationCols || rendered.rows !== destinationRows) { + rendered.resize(destinationCols, destinationRows) + } + await writeHeadlessTerminal(rendered, 'PS >') + const buffer = rendered.buffer.active + const lines = Array.from({ length: buffer.length }, (_, lineIndex) => + buffer.getLine(lineIndex)?.translateToString(true) + ) + const logicalLines: string[] = [] + for (let lineIndex = 0; lineIndex < buffer.length; lineIndex += 1) { + const line = buffer.getLine(lineIndex) + const text = line?.translateToString(true) ?? '' + if (line?.isWrapped && logicalLines.length > 0) { + logicalLines[logicalLines.length - 1] += text + } else { + logicalLines.push(text) + } + } + const visibleLines = Array.from({ length: rendered.rows }, (_, row) => + buffer.getLine(buffer.viewportY + row)?.translateToString(true) + ) + expect(buffer.baseY).toBeGreaterThan(0) + expect(lines.some((line) => line?.includes('OLD_ROW_'))).toBe(false) + expect(lines.some((line) => line?.includes('KEEP_1'))).toBe(true) + expect(lines.some((line) => line?.includes('KEEP_2'))).toBe(true) + expect(logicalLines.some((line) => /COLD\s+END/.test(line))).toBe(true) + expect(logicalLines.some((line) => line.includes('COLD_SOURCE_ROW_02'))).toBe(true) + expect(visibleLines).toEqual(['PS >', '', '', '', '']) + } finally { + rendered.dispose() + } + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts deleted file mode 100644 index 6d9c7207d22..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts +++ /dev/null @@ -1,469 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - POST_REPLAY_MODE_RESET, - RESET_GRAPHIC_RENDITION, - RELEASE_SYNCHRONIZED_OUTPUT -} from '../../../../shared/terminal-mode-reset-profiles' -import { Terminal } from '@xterm/headless' -import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' -import { - flushAsyncTicks, - writeHeadlessTerminal, - renderHeadlessTerminalState -} from './pty-connection-test-async' -import { NORMAL_BUFFER_PROLOGUE } from './pty-connection-test-constants' -import { - LEAF_1, - createMockTransport, - createPane, - createManager -} from './pty-connection-test-pane-fixtures' -import type { ConnectCallbacks, MockTransport } from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - it('paints only the daemon snapshot when reattach result includes both snapshot and replay', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: sessionId, - snapshot: 'snapshot-payload', - replay: 'replay-payload' - } - } - return null - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_GRAPHIC_RENDITION}snapshot-payload`, - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith('replay-payload', expect.any(Function)) - }) - - it('paints only relay replay when reattach result has replay and coldRestore but no snapshot', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: sessionId, - replay: 'replay-payload', - coldRestore: { scrollback: 'cold-payload' } - } - } - return null - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_GRAPHIC_RENDITION}replay-payload`, - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith('cold-payload', expect.any(Function)) - // Why: the replay branch supersedes cold-restore but must still ack, or the daemon redelivers the cold-restore payload next reattach. - expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('tab-pty') - }) - - it('blanks restored scrollback before fresh shell output', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('fresh-pty') - const written: string[] = [] - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - callbacks.onData?.('PS >') - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - ptyIdsByTabId: { 'tab-1': [] }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_1 }, - activeLeafId: LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - } as StoreState - - const pane = createPane(1) - pane.terminal.rows = 4 - pane.terminal.cols = 20 - pane.terminal.write = vi.fn((data: string, callback?: () => void) => { - written.push(data) - callback?.() - }) - const manager = createManager(1) - const deps = createDeps({ - restoredViewportBlankingPanesRef: { current: new Set([1]) } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - const blankViewport = buildFreshShellViewportBlankingSequence(4) - expect(written).toContain(blankViewport) - expect(written.indexOf(blankViewport)).toBeLessThan(written.indexOf('PS >')) - - const rendered = await renderHeadlessTerminalState( - ['old TUI row with a long tail\r\nold TUI row two', blankViewport, 'PS >'], - 20, - 4 - ) - expect(rendered.baseY).toBeGreaterThan(0) - expect(rendered.allLines.some((line) => line.includes('old TUI row'))).toBe(true) - expect(rendered.visibleLines).toEqual(['PS >', '', '', '']) - }) - - it('cold-restores at the recovered grid and clears only the dirty viewport before blanking', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('fresh-pty') - const written: string[] = [] - const operations: ( - | { kind: 'write'; data: string } - | { kind: 'resize'; cols: number; rows: number } - )[] = [] - const destinationCols = 10 - const destinationRows = 5 - const recoveredCols = 20 - const recoveredRows = 3 - const coldScrollback = '\x1b[1;1HCOLD\x1b[1;15HEND\r\nCOLD_SOURCE_ROW_02' - const groundedColdScrollback = `${RESET_GRAPHIC_RENDITION}${coldScrollback}` - const viewportClear = `${RELEASE_SYNCHRONIZED_OUTPUT}${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[H` - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: 'fresh-pty', - coldRestore: { - scrollback: coldScrollback, - cwd: '/tmp/wt-1', - cols: recoveredCols, - rows: recoveredRows - } - } - } - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] - } - } as StoreState - - const pane = createPane(1) - pane.terminal.rows = destinationRows - pane.terminal.cols = destinationCols - let sawViewportClear = false - const preResizeBarrier = { release: null as (() => void) | null } - pane.terminal.write = vi.fn((data: string, callback?: () => void) => { - written.push(data) - operations.push({ kind: 'write', data }) - if (data === viewportClear) { - sawViewportClear = true - } else if (sawViewportClear && data === '' && preResizeBarrier.release === null) { - preResizeBarrier.release = callback ?? (() => {}) - return - } - callback?.() - }) - pane.terminal.resize = vi.fn((cols: number, rows: number) => { - operations.push({ kind: 'resize', cols, rows }) - pane.terminal.cols = cols - pane.terminal.rows = rows - }) - const manager = createManager(1) - pane.fitAddon.proposeDimensions = vi.fn(() => ({ - cols: destinationCols, - rows: destinationRows - })) - pane.fitAddon.fit = vi.fn(() => { - pane.terminal.resize(destinationCols, destinationRows) - }) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - expect(preResizeBarrier.release).not.toBeNull() - expect(pane.terminal.resize).not.toHaveBeenCalledWith(recoveredCols, recoveredRows) - expect(written).not.toContain(coldScrollback) - - preResizeBarrier.release?.() - await flushAsyncTicks(20) - - const blankViewport = buildFreshShellViewportBlankingSequence(destinationRows) - expect(pane.terminal.resize).toHaveBeenCalledWith(recoveredCols, recoveredRows) - expect(transport.resize).not.toHaveBeenCalledWith(recoveredCols, recoveredRows) - expect(transport.resize).toHaveBeenCalledWith(destinationCols, destinationRows) - expect(transport.resize).toHaveBeenLastCalledWith(destinationCols, destinationRows, { - claim: true - }) - expect(written).toContain(viewportClear) - expect(written).not.toContain(NORMAL_BUFFER_PROLOGUE) - expect(written).toEqual( - expect.arrayContaining([groundedColdScrollback, POST_REPLAY_MODE_RESET, blankViewport]) - ) - expect(written.indexOf(viewportClear)).toBeLessThan(written.indexOf(groundedColdScrollback)) - expect(written.indexOf(groundedColdScrollback)).toBeLessThan(written.indexOf(blankViewport)) - const viewportClearOperation = operations.findIndex( - (operation) => operation.kind === 'write' && operation.data === viewportClear - ) - const recoveredResizeOperation = operations.findIndex( - (operation) => - operation.kind === 'resize' && - operation.cols === recoveredCols && - operation.rows === recoveredRows - ) - expect(viewportClearOperation).toBeLessThan(recoveredResizeOperation) - - // Model the real op order: the old path appended a 20-col snapshot onto a dirty 10-col xterm, wrapping rows at the wrong grid. - const rendered = new Terminal({ - cols: destinationCols, - rows: destinationRows, - allowProposedApi: true - }) - try { - await writeHeadlessTerminal( - rendered, - 'KEEP_1\r\nKEEP_2\r\nOLD_ROW_1\r\nOLD_ROW_2\r\nOLD_ROW_3\r\nOLD_ROW_4\r\nOLD_ROW_5' - ) - await writeHeadlessTerminal(rendered, '\x1b[44m') - let replayedAtRecoveredGrid = false - let sourceGridLines: string[] = [] - for (const operation of operations) { - if (operation.kind === 'resize') { - if ( - replayedAtRecoveredGrid && - operation.cols === destinationCols && - operation.rows === destinationRows && - sourceGridLines.length === 0 - ) { - sourceGridLines = Array.from( - { length: rendered.buffer.active.length }, - (_, lineIndex) => - rendered.buffer.active.getLine(lineIndex)?.translateToString(true) ?? '' - ) - } - rendered.resize(operation.cols, operation.rows) - if (operation.cols === recoveredCols && operation.rows === recoveredRows) { - replayedAtRecoveredGrid = true - } - } else { - await writeHeadlessTerminal(rendered, operation.data) - if (operation.data === viewportClear) { - expect(rendered.buffer.active.getLine(0)?.getCell(0)?.getBgColor()).toBe(-1) - } - } - } - if (sourceGridLines.length === 0) { - sourceGridLines = Array.from( - { length: rendered.buffer.active.length }, - (_, lineIndex) => rendered.buffer.active.getLine(lineIndex)?.translateToString(true) ?? '' - ) - } - expect(sourceGridLines).toContain('COLD END') - if (rendered.cols !== destinationCols || rendered.rows !== destinationRows) { - rendered.resize(destinationCols, destinationRows) - } - await writeHeadlessTerminal(rendered, 'PS >') - const buffer = rendered.buffer.active - const lines = Array.from({ length: buffer.length }, (_, lineIndex) => - buffer.getLine(lineIndex)?.translateToString(true) - ) - const logicalLines: string[] = [] - for (let lineIndex = 0; lineIndex < buffer.length; lineIndex += 1) { - const line = buffer.getLine(lineIndex) - const text = line?.translateToString(true) ?? '' - if (line?.isWrapped && logicalLines.length > 0) { - logicalLines[logicalLines.length - 1] += text - } else { - logicalLines.push(text) - } - } - const visibleLines = Array.from({ length: rendered.rows }, (_, row) => - buffer.getLine(buffer.viewportY + row)?.translateToString(true) - ) - expect(buffer.baseY).toBeGreaterThan(0) - expect(lines.some((line) => line?.includes('OLD_ROW_'))).toBe(false) - expect(lines.some((line) => line?.includes('KEEP_1'))).toBe(true) - expect(lines.some((line) => line?.includes('KEEP_2'))).toBe(true) - expect(logicalLines.some((line) => /COLD\s+END/.test(line))).toBe(true) - expect(logicalLines.some((line) => line.includes('COLD_SOURCE_ROW_02'))).toBe(true) - expect(visibleLines).toEqual(['PS >', '', '', '', '']) - } finally { - rendered.dispose() - } - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts new file mode 100644 index 00000000000..08fcfcca2e9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts @@ -0,0 +1,397 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { UUID_RE } from './pty-connection-test-constants' +import { temporarilySetNavigatorUserAgent } from './pty-connection-test-dom' +import { + LEAF_2, + createMockTransport, + createPane, + createManager, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionColdRestoreResumeCommandTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('re-runs the resume command when a hibernated local session reattaches with no payload', async () => { + // Why: the daemon drops startup commands on reattach, so a passive hibernation record must replace a contentless adopted shell. + const pendingTimeouts: (() => void)[] = [] + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = vi.fn((fn: () => void) => { + pendingTimeouts.push(fn) + return 999 as unknown as ReturnType + }) as unknown as typeof setTimeout + + try { + const { connectPanePty } = await import('./pty-connection') + const paneKey = makePaneKey('tab-1', LEAF_2) + let activePtyId: string | null = 'restored-session' + const transport = createMockTransport('restored-session') + transport.getPtyId.mockImplementation(() => activePtyId) + transport.disconnect.mockImplementation(() => { + activePtyId = null + }) + transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { + if (opts.sessionId) { + activePtyId = opts.sessionId + return { + id: opts.sessionId, + isReattach: true, + snapshot: undefined, + replay: undefined, + coldRestore: undefined + } + } + activePtyId = 'fresh-resume-pty' + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('fresh-resume-pty') + return 'fresh-resume-pty' + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'restored-session' }] }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + // Mirrors the user's scenario: a stopped/completed agent that hibernated. + state: 'done', + origin: 'worktree-sleep', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } + }) + vi.mocked(window.api.pty.declarePendingPaneSerializer) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(2) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + for (const fn of pendingTimeouts) { + fn() + } + await flushAsyncTicks(10) + + expect(transport.disconnect).toHaveBeenCalledTimes(1) + expect(transport.connect).toHaveBeenCalledTimes(2) + expect(transport.connect).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: + "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", + env: expect.objectContaining({ + ORCA_PANE_KEY: paneKey, + ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + }) + }) + ) + // The dead session is not adopted as the pane's live PTY. + expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'restored-session') + expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'restored-session') + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(2, 'restored-session') + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'fresh-resume-pty') + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) + expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(paneKey, 1) + } finally { + globalThis.setTimeout = originalSetTimeout + } + }) + + // Regression (#12320): a cold restore after reboot typed PowerShell single quotes into + // cmd.exe tabs, so the agent CLI rejected the resume argv ("unexpected argument"). + async function runWindowsColdRestoreResume(args: { + terminalWindowsShell: string + tabShellOverride?: string + }): Promise { + const restoreNavigator = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const pendingTimeouts: (() => void)[] = [] + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = vi.fn((fn: () => void) => { + pendingTimeouts.push(fn) + return 999 as unknown as ReturnType + }) as unknown as typeof setTimeout + + try { + const { connectPanePty } = await import('./pty-connection') + const paneKey = makePaneKey('tab-1', LEAF_2) + let activePtyId: string | null = 'restored-session' + const transport = createMockTransport('restored-session') + transport.getPtyId.mockImplementation(() => activePtyId) + transport.disconnect.mockImplementation(() => { + activePtyId = null + }) + transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { + if (opts.sessionId) { + activePtyId = opts.sessionId + return { + id: opts.sessionId, + isReattach: true, + snapshot: undefined, + replay: undefined, + coldRestore: undefined + } + } + activePtyId = 'fresh-resume-pty' + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('fresh-resume-pty') + return 'fresh-resume-pty' + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [ + { + id: 'tab-1', + ptyId: 'restored-session', + ...(args.tabShellOverride ? { shellOverride: args.tabShellOverride } : {}) + } + ] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {}, + terminalWindowsShell: args.terminalWindowsShell + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'done', + origin: 'worktree-sleep', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } + }) + vi.mocked(window.api.pty.declarePendingPaneSerializer) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(2) + + connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) + await flushAsyncTicks(20) + for (const fn of pendingTimeouts) { + fn() + } + await flushAsyncTicks(10) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The transport connect spy records the command string supplied by the cold-restore path. + return (transport.connect.mock.calls.at(-1)?.[0] as { command?: string } | undefined) + ?.command + } finally { + globalThis.setTimeout = originalSetTimeout + restoreNavigator() + } + } + + it('quotes a cold-restore resume command for a cmd.exe Windows tab', async () => { + await expect(runWindowsColdRestoreResume({ terminalWindowsShell: 'cmd.exe' })).resolves.toBe( + 'codex "--dangerously-bypass-approvals-and-sandbox" "resume" "codex-session-1"' + ) + }) + + it('prefers the tab shell override over the global Windows shell on cold restore', async () => { + await expect( + runWindowsColdRestoreResume({ + terminalWindowsShell: 'powershell.exe', + tabShellOverride: 'cmd.exe' + }) + ).resolves.toBe( + 'codex "--dangerously-bypass-approvals-and-sandbox" "resume" "codex-session-1"' + ) + }) + + it('keeps PowerShell quoting for a cold-restore resume on a PowerShell Windows tab', async () => { + await expect( + runWindowsColdRestoreResume({ terminalWindowsShell: 'powershell.exe' }) + ).resolves.toBe( + "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'" + ) + }) + + async function expectLiveNoteKeepsContentlessReattach( + state: 'working' | 'done' + ): Promise { + const { connectPanePty } = await import('./pty-connection') + const paneKey = makePaneKey('tab-1', LEAF_2) + const transport = createMockTransport('restored-session') + transport.connect.mockResolvedValue({ + id: 'restored-session', + isReattach: true, + snapshot: undefined, + replay: undefined, + coldRestore: undefined + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'restored-session' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + origin: 'live', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + Object.assign(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] ?? {}, { state }) + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.disconnect).not.toHaveBeenCalled() + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'restored-session') + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + } + + // Why: a live note is the idle anchor of a running pane, done or not, never hibernation evidence. + it.each(['working', 'done'] as const)( + 'keeps a contentless reattach when a live-origin %s note represents a running session', + expectLiveNoteKeepsContentlessReattach + ) + + it('keeps a contentless reattach when a live status supersedes passive sleep evidence', async () => { + const { connectPanePty } = await import('./pty-connection') + const paneKey = makePaneKey('tab-1', LEAF_2) + const transport = createMockTransport('restored-session') + transport.connect.mockResolvedValue({ + id: 'restored-session', + isReattach: true, + snapshot: undefined, + replay: undefined, + coldRestore: undefined + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'restored-session' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: { + [paneKey]: { + state: 'working', + prompt: 'new live task', + agentType: 'codex', + providerSession: { key: 'session_id', id: 'live-codex-session' }, + paneKey, + updatedAt: 2, + stateStartedAt: 2, + stateHistory: [] + } + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'old-codex-session' }, + prompt: 'old completed task', + state: 'done', + origin: 'worktree-sleep', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.disconnect).not.toHaveBeenCalled() + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'restored-session') + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts deleted file mode 100644 index fd69efe9984..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts +++ /dev/null @@ -1,479 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { UUID_RE } from './pty-connection-test-constants' -import { temporarilySetNavigatorUserAgent } from './pty-connection-test-dom' -import { - LEAF_2, - createMockTransport, - createPane, - createManager, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('re-runs the resume command when a hibernated local session reattaches with no payload', async () => { - // Why: the daemon drops startup commands on reattach, so a passive hibernation record must replace a contentless adopted shell. - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout - - try { - const { connectPanePty } = await import('./pty-connection') - const paneKey = makePaneKey('tab-1', LEAF_2) - let activePtyId: string | null = 'restored-session' - const transport = createMockTransport('restored-session') - transport.getPtyId.mockImplementation(() => activePtyId) - transport.disconnect.mockImplementation(() => { - activePtyId = null - }) - transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { - if (opts.sessionId) { - activePtyId = opts.sessionId - return { - id: opts.sessionId, - isReattach: true, - snapshot: undefined, - replay: undefined, - coldRestore: undefined - } - } - activePtyId = 'fresh-resume-pty' - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - onPtySpawn?.('fresh-resume-pty') - return 'fresh-resume-pty' - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'restored-session' }] }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {} - }, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - // Mirrors the user's scenario: a stopped/completed agent that hibernated. - state: 'done', - origin: 'worktree-sleep', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } - }) - vi.mocked(window.api.pty.declarePendingPaneSerializer) - .mockResolvedValueOnce(1) - .mockResolvedValueOnce(2) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - for (const fn of pendingTimeouts) { - fn() - } - await flushAsyncTicks(10) - - expect(transport.disconnect).toHaveBeenCalledTimes(1) - expect(transport.connect).toHaveBeenCalledTimes(2) - expect(transport.connect).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", - env: expect.objectContaining({ - ORCA_PANE_KEY: paneKey, - ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) - }) - }) - ) - // The dead session is not adopted as the pane's live PTY. - expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'restored-session') - expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'restored-session') - expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(2, 'restored-session') - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'fresh-resume-pty') - expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) - expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(paneKey, 1) - } finally { - globalThis.setTimeout = originalSetTimeout - } - }) - - // Regression (#12320): a cold restore after reboot typed PowerShell single quotes into - // cmd.exe tabs, so the agent CLI rejected the resume argv ("unexpected argument"). - async function runWindowsColdRestoreResume(args: { - terminalWindowsShell: string - tabShellOverride?: string - }): Promise { - const restoreNavigator = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout - - try { - const { connectPanePty } = await import('./pty-connection') - const paneKey = makePaneKey('tab-1', LEAF_2) - let activePtyId: string | null = 'restored-session' - const transport = createMockTransport('restored-session') - transport.getPtyId.mockImplementation(() => activePtyId) - transport.disconnect.mockImplementation(() => { - activePtyId = null - }) - transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { - if (opts.sessionId) { - activePtyId = opts.sessionId - return { - id: opts.sessionId, - isReattach: true, - snapshot: undefined, - replay: undefined, - coldRestore: undefined - } - } - activePtyId = 'fresh-resume-pty' - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - onPtySpawn?.('fresh-resume-pty') - return 'fresh-resume-pty' - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [ - { - id: 'tab-1', - ptyId: 'restored-session', - ...(args.tabShellOverride ? { shellOverride: args.tabShellOverride } : {}) - } - ] - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {}, - terminalWindowsShell: args.terminalWindowsShell - }, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'done', - origin: 'worktree-sleep', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } - }) - vi.mocked(window.api.pty.declarePendingPaneSerializer) - .mockResolvedValueOnce(1) - .mockResolvedValueOnce(2) - - connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) - await flushAsyncTicks(20) - for (const fn of pendingTimeouts) { - fn() - } - await flushAsyncTicks(10) - - return (transport.connect.mock.calls.at(-1)?.[0] as { command?: string } | undefined)?.command - } finally { - globalThis.setTimeout = originalSetTimeout - restoreNavigator() - } - } - - it('quotes a cold-restore resume command for a cmd.exe Windows tab', async () => { - await expect(runWindowsColdRestoreResume({ terminalWindowsShell: 'cmd.exe' })).resolves.toBe( - 'codex "--dangerously-bypass-approvals-and-sandbox" "resume" "codex-session-1"' - ) - }) - - it('prefers the tab shell override over the global Windows shell on cold restore', async () => { - await expect( - runWindowsColdRestoreResume({ - terminalWindowsShell: 'powershell.exe', - tabShellOverride: 'cmd.exe' - }) - ).resolves.toBe('codex "--dangerously-bypass-approvals-and-sandbox" "resume" "codex-session-1"') - }) - - it('keeps PowerShell quoting for a cold-restore resume on a PowerShell Windows tab', async () => { - await expect( - runWindowsColdRestoreResume({ terminalWindowsShell: 'powershell.exe' }) - ).resolves.toBe("codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'") - }) - - async function expectLiveNoteKeepsContentlessReattach(state: 'working' | 'done'): Promise { - const { connectPanePty } = await import('./pty-connection') - const paneKey = makePaneKey('tab-1', LEAF_2) - const transport = createMockTransport('restored-session') - transport.connect.mockResolvedValue({ - id: 'restored-session', - isReattach: true, - snapshot: undefined, - replay: undefined, - coldRestore: undefined - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'restored-session' }] }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - origin: 'live', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - Object.assign(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] ?? {}, { state }) - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(transport.connect).toHaveBeenCalledTimes(1) - expect(transport.disconnect).not.toHaveBeenCalled() - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'restored-session') - expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() - } - - // Why: a live note is the idle anchor of a running pane, done or not, never hibernation evidence. - it.each(['working', 'done'] as const)( - 'keeps a contentless reattach when a live-origin %s note represents a running session', - expectLiveNoteKeepsContentlessReattach - ) - - it('keeps a contentless reattach when a live status supersedes passive sleep evidence', async () => { - const { connectPanePty } = await import('./pty-connection') - const paneKey = makePaneKey('tab-1', LEAF_2) - const transport = createMockTransport('restored-session') - transport.connect.mockResolvedValue({ - id: 'restored-session', - isReattach: true, - snapshot: undefined, - replay: undefined, - coldRestore: undefined - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'restored-session' }] }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, - agentStatusByPaneKey: { - [paneKey]: { - state: 'working', - prompt: 'new live task', - agentType: 'codex', - providerSession: { key: 'session_id', id: 'live-codex-session' }, - paneKey, - updatedAt: 2, - stateStartedAt: 2, - stateHistory: [] - } - }, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'old-codex-session' }, - prompt: 'old completed task', - state: 'done', - origin: 'worktree-sleep', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(transport.connect).toHaveBeenCalledTimes(1) - expect(transport.disconnect).not.toHaveBeenCalled() - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'restored-session') - expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.spec.ts new file mode 100644 index 00000000000..0630eb82115 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.spec.ts @@ -0,0 +1,855 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { POST_REPLAY_REATTACH_RESET } from '../../../../shared/terminal-mode-reset-profiles' +import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { + createKeyboardEventTarget, + keyEvent, + sendTerminalInputThroughPane +} from './pty-connection-test-dom' +import { + LEAF_1, + createMockTransport, + createPane, + captureCallbackTerminalWrites, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { + resolveMockPaneWindowsShiftEnterEncoding, + type StoreState +} from './pty-connection-test-store-state' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionCommandFinishedCleanupTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it.each(['process', 'launch'] as const)( + 'does not let typed Droid input replace another live TUI %s identity', + async (identitySource) => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const ptyId = `pty-antigravity-${identitySource}-typed-droid` + const tabId = `tab-antigravity-${identitySource}-typed-droid` + const paneKey = makePaneKey(tabId, LEAF_1) + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + if (identitySource === 'process') { + mockStoreState.paneForegroundAgentByPaneKey[paneKey] = { + agent: 'antigravity', + shellForeground: false + } + } else { + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'antigravity' } + } + } + + sendTerminalInputThroughPane(pane, 'droid\r') + dataCallbackRef.current?.('\x1b]133;C\x07') + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + } + ) + + it.each([ + ['SSH', 'ssh:conn@@pty-typed-droid', 'tab-ssh-typed-droid'], + ['remote runtime', 'remote:web-env-1@@pty-typed-droid', 'tab-remote-typed-droid'] + ])( + 'does not persist typed command process evidence for %s panes', + async (_label, ptyId, tabId) => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const paneKey = makePaneKey(tabId, LEAF_1) + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + + sendTerminalInputThroughPane(pane, 'droid\r') + dataCallbackRef.current?.('\x1b]133;C\x07\x1b]133;D;0\x07') + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toBeUndefined() + expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalledWith( + paneKey, + expect.anything() + ) + } + ) + + it('keeps Droid routing visible through command-finished foreground confirmation', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const getForegroundProcess = vi.mocked(window.api.pty.confirmForegroundProcess) + getForegroundProcess.mockResolvedValue('droid') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-droid-confirmation-window' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { + delete mockStoreState.agentLaunchConfigByPaneKey[key] + }) + + const readsBeforeFinish = getForegroundProcess.mock.calls.length + dataCallbackRef.current?.('\x1b]133;D;0\x07') + expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + await vi.advanceTimersByTimeAsync(350) + expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + + expect(getForegroundProcess).toHaveBeenCalledTimes(readsBeforeFinish + 1) + expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('retires pane launch routing after one fresh scan confirms shell', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-droid-confirmed-shell' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { + delete mockStoreState.agentLaunchConfigByPaneKey[key] + }) + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350) + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: true + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + // Why: `agentStatus:drop` deliberately preserves a live pane's per-pane caches, so a + // process-confirmed agent exit must go through the reconcile route instead — otherwise a + // surviving Claude latch resolves the pane's next event straight back to 'working' (STA-4612). + expect(window.api.agentStatus.reconcileEndedProcess).toHaveBeenCalledWith(paneKey) + }) + + it.each([ + ['clears stale sleeping identity after a confirmed return to shell', false], + // Why: a hibernation records the pane's resume before its kill; the agent dying first + // surfaces the shell, which is Orca's own stop rather than the agent exiting. + ['keeps sleeping identity when the shell surfaces during an intentional stop', true] + ])('%s', async (_label, stoppingOnPurpose) => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-sleeping-confirmed-shell' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'codex' } + } + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'sess-1' }, + capturedAt: 1, + updatedAt: 1 + } + if (stoppingOnPurpose) { + mockStoreState.suppressedPtyExitIds[ptyId] = true + } + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350) + + if (stoppingOnPurpose) { + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toMatchObject({ + agent: 'codex' + }) + } else { + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledExactlyOnceWith(paneKey) + expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() + } + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: true + }) + }) + + it('keeps sleeping identity when the foreground check still sees the agent', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + // Why both: the pane's process monitor reads the same foreground the confirm read sees. + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-sleeping-leaked-shell-marker' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'codex' } + } + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'sess-1' }, + capturedAt: 1, + updatedAt: 1 + } + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toMatchObject({ + agent: 'codex' + }) + }) + + it('reconciles a pane that HAS a status row, whose own drop removes it first', async () => { + // Why this shape: the sibling test above covers a pane with no row, where the deferred drop + // early-returns and never touches the pane's accepted-status bookkeeping. That is not the + // production case. A pane worth reconciling always HAS a row — and settling runs the drop + // BEFORE the reconcile, so the reconcile must survive its own drop having already fired. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + // Both imported after vi.resetModules() so they share pty-connection's module registry; a + // static top-level import here is a DIFFERENT instance and the assertion goes vacuous. + const { createTestStore } = await import('@/store/slices/store-test-helpers') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear() + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-claude-row-confirmed-shell' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + + // A real accepted status write, through the real slice, so the row carries whatever + // bookkeeping the store stamps on an accepted write. `working` is the shape STA-4612 is about: + // a latch is holding the pane open and the agent is about to exit out from under it. + const realStore = createTestStore() + realStore + .getState() + .setAgentStatus(paneKey, { state: 'working', prompt: 'ship it', agentType: 'claude' }) + const row = realStore.getState().agentStatusByPaneKey[paneKey] + expect(row).toBeDefined() + mockStoreState.agentStatusByPaneKey[paneKey] = row + // The real dismissal removes the row and runs the slice's own teardown; mirror both, or the + // ordering this test exists to pin is never exercised. + mockStoreState.dropAgentStatus.mockImplementation((key: string) => { + delete mockStoreState.agentStatusByPaneKey[key] + realStore.getState().dropAgentStatus(key) + }) + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350) + + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + expect(window.api.agentStatus.reconcileEndedProcess).toHaveBeenCalledWith(paneKey) + }) + + it('does NOT reconcile when the process check could not confirm a shell', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) + vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear() + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-unconfirmed-shell' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + // Run the whole confirm ladder. At 350ms the unavailable branch has not settled yet, so + // asserting there proves nothing — it would pass just as happily if this path DID tear the + // pane down a moment later. + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + // The unavailable branch really ran: it publishes an unproven foreground before settling. + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: false + }) + expect(window.api.agentStatus.reconcileEndedProcess).not.toHaveBeenCalled() + }) + + it('disarms stale TUI modes in the emulator after a confirmed return to shell', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('bash') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-stale-mode-disarm' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const { writes } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + + connectPanePty( + pane as never, + createManager(1) as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture types intentionally model the production connection boundary. + deps as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + + // The agent negotiates Kitty keys across a chunk split, then dies armed. + dataCallbackRef.current?.('\x1b[>') + dataCallbackRef.current?.('5u') + // A SIGKILLed agent emits no mode teardown; only the shell's next prompt + // mark arrives. The bare 133;D must not disarm yet — the foreground read + // has not confirmed the agent is gone. + dataCallbackRef.current?.('\x1b]133;D;0\x07') + expect(writes.some((w) => w.includes(POST_REPLAY_REATTACH_RESET))).toBe(false) + expect(deps.paneKittyKeyboardModesRef.current.get(pane.id)?.flags).toBe(5) + + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() + + const disarm = writes.find((w) => w.includes(POST_REPLAY_REATTACH_RESET)) + expect(disarm).toBeDefined() + // The live shell re-arms bracketed paste at its prompt before the disarm + // fires; the reset must not strip it. + expect(disarm).not.toContain('\x1b[?2004l') + // Shortcut policy reads the mirror, so it must drop the dead app's flags with xterm. + expect(deps.paneKittyKeyboardModesRef.current.get(pane.id)?.flags).toBe(0) + }) + + it('grounds xterm and the input mirror in stream order when output is still queued', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const confirmRead = createDeferred() + vi.mocked(window.api.pty.confirmForegroundProcess).mockReturnValue(confirmRead.promise) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-stale-mode-queued-output' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const { writes } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + + connectPanePty( + pane as never, + createManager(1) as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture types intentionally model the production connection boundary. + deps as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalled() + + // Hidden-pane output sits in the background queue when the shell proof lands. + dataCallbackRef.current?.('\x1b[>5u') + expect(writes.some((w) => w.includes('\x1b[>5u'))).toBe(false) + confirmRead.resolve('bash') + await flushAsyncTicks() + await vi.advanceTimersByTimeAsync(100) + + // Parsing what xterm received, in order, must match the mirror. + const xtermView = new TerminalKittyKeyboardModeTracker() + writes.forEach((write) => xtermView.scan(write)) + expect(writes.some((w) => w.includes(POST_REPLAY_REATTACH_RESET))).toBe(true) + expect(deps.paneKittyKeyboardModesRef.current.get(pane.id)?.flags).toBe(0) + expect(xtermView.flags).toBe(0) + }) + + it('keeps armed modes while the agent still owns the foreground after a leaked 133;D', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + // Why both: the pane's process monitor reads the same foreground the confirm read sees. + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('droid') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-stale-mode-live-agent' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const { writes } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + + connectPanePty( + pane as never, + createManager(1) as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture types intentionally model the production connection boundary. + deps as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + + dataCallbackRef.current?.('\x1b[>5u') + // A full-screen agent's nested command shell leaks a 133;D onto the main + // PTY; the confirming read republishes the live agent, so its armed + // mouse/kitty modes must survive untouched. + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(writes.some((w) => w.includes(POST_REPLAY_REATTACH_RESET))).toBe(false) + expect(deps.paneKittyKeyboardModesRef.current.get(pane.id)?.flags).toBe(5) + }) + + it('retires stale routing after unavailable command-finish reads without asserting shell', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-droid-unavailable-finish' + const tabId = 'tab-droid-unavailable-finish' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey(tabId, LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { + delete mockStoreState.agentLaunchConfigByPaneKey[key] + }) + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + }) + + it('cancels deferred cleanup on a new command and retires identity at its later shell', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-droid-superseded-confirmation' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { + delete mockStoreState.agentLaunchConfigByPaneKey[key] + }) + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + dataCallbackRef.current?.('\x1b]133;C\x07') + expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + }) + + it.each(['gemini', 'codex'] as const)( + 'pins %s interrupt policy before acknowledged input and command exit cleanup', + async (agentType) => { + const { connectPanePty } = await import('./pty-connection') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport() + const writeAccepted = createDeferred() + transport.sendInputAccepted = vi.fn(() => writeAccepted.promise) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return { id: 'tab-pty' } + } + ) + transport.attach.mockImplementation(({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + }) + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'stop quickly', + updatedAt: 1_000, + stateStartedAt: 900, + agentType, + terminalTitle: agentType, + stateHistory: [] + } + } + } + vi.mocked(window.api.agentStatus.inferInterrupt).mockImplementation(async () => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + paneKey, + state: 'done', + prompt: 'stop quickly', + updatedAt: 1_100, + stateStartedAt: 1_100, + agentType, + terminalTitle: agentType, + interrupted: true, + stateHistory: [ + { + state: 'working', + prompt: 'stop quickly', + startedAt: 900 + } + ] + } + return true + }) + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + vi.advanceTimersByTime(1_000) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'Escape' })) + ;(onDataHandler as unknown as (data: string) => void)('\x1b') + + capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + + delete mockStoreState.agentStatusByPaneKey[paneKey] + writeAccepted.resolve(true) + await flushAsyncTicks() + + if (agentType === 'codex') { + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) + } else { + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop quickly', + baselineAgentType: agentType, + intent: 'plain-escape' + }) + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + } + } + ) + + it('drops the command-finished status when pending interrupt inference is rejected', async () => { + const { connectPanePty } = await import('./pty-connection') + + vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(false) + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport() + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return { id: 'tab-pty' } + } + ) + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'stop quickly', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'custom-agent', + terminalTitle: 'Codex', + stateHistory: [] + } + } + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + vi.advanceTimersByTime(1_000) + await flushAsyncTicks() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + + capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') + await flushAsyncTicks() + + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts deleted file mode 100644 index 290d7861464..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts +++ /dev/null @@ -1,920 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { POST_REPLAY_REATTACH_RESET } from '../../../../shared/terminal-mode-reset-profiles' -import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' -import { - createKeyboardEventTarget, - keyEvent, - sendTerminalInputThroughPane -} from './pty-connection-test-dom' -import { - LEAF_1, - createMockTransport, - createPane, - captureCallbackTerminalWrites, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { - resolveMockPaneWindowsShiftEnterEncoding, - type StoreState -} from './pty-connection-test-store-state' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it.each(['process', 'launch'] as const)( - 'does not let typed Droid input replace another live TUI %s identity', - async (identitySource) => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const pane = createPane(1) - const ptyId = `pty-antigravity-${identitySource}-typed-droid` - const tabId = `tab-antigravity-${identitySource}-typed-droid` - const paneKey = makePaneKey(tabId, LEAF_1) - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - } - ) - transportFactoryQueue.push(transport) - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ tabId, isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - if (identitySource === 'process') { - mockStoreState.paneForegroundAgentByPaneKey[paneKey] = { - agent: 'antigravity', - shellForeground: false - } - } else { - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'antigravity' } - } - } - - sendTerminalInputThroughPane(pane, 'droid\r') - dataCallbackRef.current?.('\x1b]133;C\x07') - - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: null, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - } - ) - - it.each([ - ['SSH', 'ssh:conn@@pty-typed-droid', 'tab-ssh-typed-droid'], - ['remote runtime', 'remote:web-env-1@@pty-typed-droid', 'tab-remote-typed-droid'] - ])( - 'does not persist typed command process evidence for %s panes', - async (_label, ptyId, tabId) => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const pane = createPane(1) - const paneKey = makePaneKey(tabId, LEAF_1) - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - } - ) - transportFactoryQueue.push(transport) - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ tabId, isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - - sendTerminalInputThroughPane(pane, 'droid\r') - dataCallbackRef.current?.('\x1b]133;C\x07\x1b]133;D;0\x07') - - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toBeUndefined() - expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalledWith( - paneKey, - expect.anything() - ) - } - ) - - it('keeps Droid routing visible through command-finished foreground confirmation', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const getForegroundProcess = vi.mocked(window.api.pty.confirmForegroundProcess) - getForegroundProcess.mockResolvedValue('droid') - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'pty-droid-confirmation-window' - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'droid' } - } - mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { - delete mockStoreState.agentLaunchConfigByPaneKey[key] - }) - - const readsBeforeFinish = getForegroundProcess.mock.calls.length - dataCallbackRef.current?.('\x1b]133;D;0\x07') - expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - - await vi.advanceTimersByTimeAsync(350) - expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') - - expect(getForegroundProcess).toHaveBeenCalledTimes(readsBeforeFinish + 1) - expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: 'droid', - agentEvidence: 'process-read', - routingTrusted: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') - }) - - it('retires pane launch routing after one fresh scan confirms shell', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'pty-droid-confirmed-shell' - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'droid' } - } - mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { - delete mockStoreState.agentLaunchConfigByPaneKey[key] - }) - - dataCallbackRef.current?.('\x1b]133;D;0\x07') - await vi.advanceTimersByTimeAsync(350) - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: null, - shellForeground: true - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - // Why: `agentStatus:drop` deliberately preserves a live pane's per-pane caches, so a - // process-confirmed agent exit must go through the reconcile route instead — otherwise a - // surviving Claude latch resolves the pane's next event straight back to 'working' (STA-4612). - expect(window.api.agentStatus.reconcileEndedProcess).toHaveBeenCalledWith(paneKey) - }) - - it.each([ - ['clears stale sleeping identity after a confirmed return to shell', false], - // Why: a hibernation records the pane's resume before its kill; the agent dying first - // surfaces the shell, which is Orca's own stop rather than the agent exiting. - ['keeps sleeping identity when the shell surfaces during an intentional stop', true] - ])('%s', async (_label, stoppingOnPurpose) => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'pty-sleeping-confirmed-shell' - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'codex' } - } - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'sess-1' }, - capturedAt: 1, - updatedAt: 1 - } - if (stoppingOnPurpose) { - mockStoreState.suppressedPtyExitIds[ptyId] = true - } - - dataCallbackRef.current?.('\x1b]133;D;0\x07') - await vi.advanceTimersByTimeAsync(350) - - if (stoppingOnPurpose) { - expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() - expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toMatchObject({ - agent: 'codex' - }) - } else { - expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledExactlyOnceWith(paneKey) - expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() - } - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: null, - shellForeground: true - }) - }) - - it('keeps sleeping identity when the foreground check still sees the agent', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - // Why both: the pane's process monitor reads the same foreground the confirm read sees. - vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex') - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'pty-sleeping-leaked-shell-marker' - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'codex' } - } - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'sess-1' }, - capturedAt: 1, - updatedAt: 1 - } - - dataCallbackRef.current?.('\x1b]133;D;0\x07') - await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - await flushAsyncTicks() - - expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() - expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toMatchObject({ agent: 'codex' }) - }) - - it('reconciles a pane that HAS a status row, whose own drop removes it first', async () => { - // Why this shape: the sibling test above covers a pane with no row, where the deferred drop - // early-returns and never touches the pane's accepted-status bookkeeping. That is not the - // production case. A pane worth reconciling always HAS a row — and settling runs the drop - // BEFORE the reconcile, so the reconcile must survive its own drop having already fired. - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - // Both imported after vi.resetModules() so they share pty-connection's module registry; a - // static top-level import here is a DIFFERENT instance and the assertion goes vacuous. - const { createTestStore } = await import('@/store/slices/store-test-helpers') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') - vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear() - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'pty-claude-row-confirmed-shell' - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - - // A real accepted status write, through the real slice, so the row carries whatever - // bookkeeping the store stamps on an accepted write. `working` is the shape STA-4612 is about: - // a latch is holding the pane open and the agent is about to exit out from under it. - const realStore = createTestStore() - realStore - .getState() - .setAgentStatus(paneKey, { state: 'working', prompt: 'ship it', agentType: 'claude' }) - const row = realStore.getState().agentStatusByPaneKey[paneKey] - expect(row).toBeDefined() - mockStoreState.agentStatusByPaneKey[paneKey] = row - // The real dismissal removes the row and runs the slice's own teardown; mirror both, or the - // ordering this test exists to pin is never exercised. - mockStoreState.dropAgentStatus.mockImplementation((key: string) => { - delete mockStoreState.agentStatusByPaneKey[key] - realStore.getState().dropAgentStatus(key) - }) - - dataCallbackRef.current?.('\x1b]133;D;0\x07') - await vi.advanceTimersByTimeAsync(350) - - expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) - expect(window.api.agentStatus.reconcileEndedProcess).toHaveBeenCalledWith(paneKey) - }) - - it('does NOT reconcile when the process check could not confirm a shell', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) - vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear() - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'pty-unconfirmed-shell' - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'droid' } - } - - dataCallbackRef.current?.('\x1b]133;D;0\x07') - // Run the whole confirm ladder. At 350ms the unavailable branch has not settled yet, so - // asserting there proves nothing — it would pass just as happily if this path DID tear the - // pane down a moment later. - await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - await flushAsyncTicks() - - // The unavailable branch really ran: it publishes an unproven foreground before settling. - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: null, - shellForeground: false - }) - expect(window.api.agentStatus.reconcileEndedProcess).not.toHaveBeenCalled() - }) - - it('disarms stale TUI modes in the emulator after a confirmed return to shell', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('bash') - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'pty-stale-mode-disarm' - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const { writes } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ isVisibleRef: { current: false } }) - - connectPanePty( - pane as never, - createManager(1) as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture types intentionally model the production connection boundary. - deps as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'droid' } - } - - // The agent negotiates Kitty keys across a chunk split, then dies armed. - dataCallbackRef.current?.('\x1b[>') - dataCallbackRef.current?.('5u') - // A SIGKILLed agent emits no mode teardown; only the shell's next prompt - // mark arrives. The bare 133;D must not disarm yet — the foreground read - // has not confirmed the agent is gone. - dataCallbackRef.current?.('\x1b]133;D;0\x07') - expect(writes.some((w) => w.includes(POST_REPLAY_REATTACH_RESET))).toBe(false) - expect(deps.paneKittyKeyboardModesRef.current.get(pane.id)?.flags).toBe(5) - - await vi.advanceTimersByTimeAsync(350) - await flushAsyncTicks() - - const disarm = writes.find((w) => w.includes(POST_REPLAY_REATTACH_RESET)) - expect(disarm).toBeDefined() - // The live shell re-arms bracketed paste at its prompt before the disarm - // fires; the reset must not strip it. - expect(disarm).not.toContain('\x1b[?2004l') - // Shortcut policy reads the mirror, so it must drop the dead app's flags with xterm. - expect(deps.paneKittyKeyboardModesRef.current.get(pane.id)?.flags).toBe(0) - }) - - it('grounds xterm and the input mirror in stream order when output is still queued', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const confirmRead = createDeferred() - vi.mocked(window.api.pty.confirmForegroundProcess).mockReturnValue(confirmRead.promise) - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'pty-stale-mode-queued-output' - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const { writes } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ isVisibleRef: { current: false } }) - - connectPanePty( - pane as never, - createManager(1) as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture types intentionally model the production connection boundary. - deps as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'droid' } - } - dataCallbackRef.current?.('\x1b]133;D;0\x07') - await vi.advanceTimersByTimeAsync(350) - await flushAsyncTicks() - expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalled() - - // Hidden-pane output sits in the background queue when the shell proof lands. - dataCallbackRef.current?.('\x1b[>5u') - expect(writes.some((w) => w.includes('\x1b[>5u'))).toBe(false) - confirmRead.resolve('bash') - await flushAsyncTicks() - await vi.advanceTimersByTimeAsync(100) - - // Parsing what xterm received, in order, must match the mirror. - const xtermView = new TerminalKittyKeyboardModeTracker() - writes.forEach((write) => xtermView.scan(write)) - expect(writes.some((w) => w.includes(POST_REPLAY_REATTACH_RESET))).toBe(true) - expect(deps.paneKittyKeyboardModesRef.current.get(pane.id)?.flags).toBe(0) - expect(xtermView.flags).toBe(0) - }) - - it('keeps armed modes while the agent still owns the foreground after a leaked 133;D', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - // Why both: the pane's process monitor reads the same foreground the confirm read sees. - vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('droid') - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'pty-stale-mode-live-agent' - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const { writes } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ isVisibleRef: { current: false } }) - - connectPanePty( - pane as never, - createManager(1) as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture types intentionally model the production connection boundary. - deps as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'droid' } - } - - dataCallbackRef.current?.('\x1b[>5u') - // A full-screen agent's nested command shell leaks a 133;D onto the main - // PTY; the confirming read republishes the live agent, so its armed - // mouse/kitty modes must survive untouched. - dataCallbackRef.current?.('\x1b]133;D;0\x07') - await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - await flushAsyncTicks() - - expect(writes.some((w) => w.includes(POST_REPLAY_REATTACH_RESET))).toBe(false) - expect(deps.paneKittyKeyboardModesRef.current.get(pane.id)?.flags).toBe(5) - }) - - it('retires stale routing after unavailable command-finish reads without asserting shell', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'pty-droid-unavailable-finish' - const tabId = 'tab-droid-unavailable-finish' - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey(tabId, LEAF_1) - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ tabId, isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'droid' } - } - mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { - delete mockStoreState.agentLaunchConfigByPaneKey[key] - }) - - dataCallbackRef.current?.('\x1b]133;D;0\x07') - await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - await flushAsyncTicks() - - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: null, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - }) - - it('cancels deferred cleanup on a new command and retires identity at its later shell', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'pty-droid-superseded-confirmation' - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'droid' } - } - mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { - delete mockStoreState.agentLaunchConfigByPaneKey[key] - }) - - dataCallbackRef.current?.('\x1b]133;D;0\x07') - dataCallbackRef.current?.('\x1b]133;C\x07') - expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - - dataCallbackRef.current?.('\x1b]133;D;0\x07') - await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - }) - - it.each(['gemini', 'codex'] as const)( - 'pins %s interrupt policy before acknowledged input and command exit cleanup', - async (agentType) => { - const { connectPanePty } = await import('./pty-connection') - - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport() - const writeAccepted = createDeferred() - transport.sendInputAccepted = vi.fn(() => writeAccepted.promise) - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return { id: 'tab-pty' } - } - ) - transport.attach.mockImplementation(({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - }) - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - state: 'working', - prompt: 'stop quickly', - updatedAt: 1_000, - stateStartedAt: 900, - agentType, - terminalTitle: agentType, - stateHistory: [] - } - } - } - vi.mocked(window.api.agentStatus.inferInterrupt).mockImplementation(async () => { - mockStoreState.agentStatusByPaneKey[paneKey] = { - paneKey, - state: 'done', - prompt: 'stop quickly', - updatedAt: 1_100, - stateStartedAt: 1_100, - agentType, - terminalTitle: agentType, - interrupted: true, - stateHistory: [ - { - state: 'working', - prompt: 'stop quickly', - startedAt: 900 - } - ] - } - return true - }) - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - vi.advanceTimersByTime(1_000) - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - terminalTarget.dispatch(keyEvent({ key: 'Escape' })) - ;(onDataHandler as unknown as (data: string) => void)('\x1b') - - capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - - delete mockStoreState.agentStatusByPaneKey[paneKey] - writeAccepted.resolve(true) - await flushAsyncTicks() - - if (agentType === 'codex') { - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) - } else { - expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ - paneKey, - baselineUpdatedAt: 1_000, - baselineStateStartedAt: 900, - baselinePrompt: 'stop quickly', - baselineAgentType: agentType, - intent: 'plain-escape' - }) - expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) - } - } - ) - - it('drops the command-finished status when pending interrupt inference is rejected', async () => { - const { connectPanePty } = await import('./pty-connection') - - vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(false) - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport() - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return { id: 'tab-pty' } - }) - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - state: 'working', - prompt: 'stop quickly', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'custom-agent', - terminalTitle: 'Codex', - stateHistory: [] - } - } - } - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - vi.advanceTimersByTime(1_000) - await flushAsyncTicks() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - ;(onDataHandler as unknown as (data: string) => void)('\x03') - - capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - await flushAsyncTicks() - - expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalled() - expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts new file mode 100644 index 00000000000..0a34d8d1515 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-completion-and-notification.test.ts @@ -0,0 +1,34 @@ +import { describe } from 'vitest' +import { registerPtyConnectionCommandFinishedCleanupTests } from './pty-connection-command-finished-cleanup.spec' +import { registerPtyConnectionHookCompletionBellArbitrationTests } from './pty-connection-hook-completion-bell-arbitration.spec' +import { registerPtyConnectionHookCompletionSideEffectsTests } from './pty-connection-hook-completion-side-effects.spec' +import { registerPtyConnectionMainSideEffectAuthorityTests } from './pty-connection-main-side-effect-authority.spec' +import { registerPtyConnectionNotificationSettingsGatingTests } from './pty-connection-notification-settings-gating.spec' +import { registerPtyConnectionTaskCompleteDispatchTests } from './pty-connection-task-complete-dispatch.spec' +import { registerPtyConnectionUnansweredCommandFinishTests } from './pty-connection-unanswered-command-finish.spec' + +describe( + 'pty-connection-command-finished-cleanup', + registerPtyConnectionCommandFinishedCleanupTests +) +describe( + 'pty-connection-hook-completion-bell-arbitration', + registerPtyConnectionHookCompletionBellArbitrationTests +) +describe( + 'pty-connection-hook-completion-side-effects', + registerPtyConnectionHookCompletionSideEffectsTests +) +describe( + 'pty-connection-main-side-effect-authority', + registerPtyConnectionMainSideEffectAuthorityTests +) +describe( + 'pty-connection-notification-settings-gating', + registerPtyConnectionNotificationSettingsGatingTests +) +describe('pty-connection-task-complete-dispatch', registerPtyConnectionTaskCompleteDispatchTests) +describe( + 'pty-connection-unanswered-command-finish', + registerPtyConnectionUnansweredCommandFinishTests +) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.spec.ts new file mode 100644 index 00000000000..465d21dfcb0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.spec.ts @@ -0,0 +1,793 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + POST_REPLAY_DEAD_TUI_RESET, + POST_REPLAY_MODE_RESET, + POST_REPLAY_REATTACH_RESET, + RESET_GRAPHIC_RENDITION, + RELEASE_SYNCHRONIZED_OUTPUT +} from '../../../../shared/terminal-mode-reset-profiles' +import { replayEpilogue } from './pty-connection-test-replay-epilogue' +import { Terminal } from '@xterm/headless' +import { flushAsyncTicks, createDeferred, writeHeadlessTerminal } from './pty-connection-test-async' +import { createRect } from './pty-connection-test-dom' +import { + LEAF_1, + createMockTransport, + createPane, + captureCallbackTerminalWrites, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionDaemonSnapshotReplayTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('clears the captured pen for a normal-buffer fallback reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + const snapshot = 'ORCA-SGR-REPRO \x1b[1mBOLD-RUN-LEFT-OPEN\x1b[1;34H' + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] + }, + settings: { + ...mockStoreState.settings + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RELEASE_SYNCHRONIZED_OUTPUT}${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`, + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}${snapshot}`, + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_REATTACH_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_MODE_RESET, 0), + expect.any(Function) + ) + + const rendered = new Terminal({ cols: 40, rows: 6, allowProposedApi: true }) + try { + await writeHeadlessTerminal(rendered, '\x1b[1;44mDIRTY') + for (const [data] of pane.terminal.write.mock.calls) { + if (data) { + await writeHeadlessTerminal(rendered, data) + } + } + await writeHeadlessTerminal(rendered, 'PLAIN') + const line = rendered.buffer.active.getLine(rendered.buffer.active.baseY) + const plainColumn = line?.translateToString(true).indexOf('PLAIN') ?? -1 + + expect(line?.getCell(plainColumn)?.isBold()).toBe(0) + expect(line?.getCell(plainColumn)?.getFgColor()).toBe(-1) + expect(line?.getCell(plainColumn)?.getBgColor()).toBe(-1) + expect(rendered.buffer.active.getLine(5)?.getCell(39)?.getBgColor()).toBe(-1) + } finally { + rendered.dispose() + } + }) + + it('returns a shell-owned daemon snapshot to the normal buffer on reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: '\x1b[?1049h\x1b[?1003hSTALE-TUI', + isAlternateScreen: true, + snapshotTerminalOwner: 'shell' + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), + expect.any(Function) + ) + }) + + it('drops a too-wide daemon alt frame and keeps the scrollback prefix', async () => { + // Why: fixed-grid alt rows clip at a narrower viewport until the owner repaints. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('PREFIX-SCROLLBACK') + expect(writes.join('')).toContain('RESTORE-LIVE-STATE') + expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + }) + + it('keeps a daemon alt frame at its capture grid while the target grid is unknown', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 120, + snapshotRows: 40, + isAlternateScreen: true + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.container.getBoundingClientRect = vi.fn(() => createRect(0, 0)) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 1, rows: 1 })) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('PREFIX-SCROLLBACK') + expect(writes.join('')).toContain('ALT-FRAME-BODY') + expect(writes.filter((write) => write.includes('ALT-FRAME-BODY'))).toHaveLength(1) + }) + + it('falls back to the merged daemon snapshot when split metadata is incomplete', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'LEGACY-MERGED-SNAPSHOT', + snapshotPrefixAnsi: 'INCOMPLETE-PREFIX', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('LEGACY-MERGED-SNAPSHOT') + expect(writes.join('')).not.toContain('INCOMPLETE-PREFIX') + }) + + it('keeps a daemon alt frame when the pane is not narrower than the capture', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 120, + snapshotRows: 40, + isAlternateScreen: true + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect( + (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]).join('') + ).toContain('ALT-FRAME-BODY') + }) + + it('drops a too-wide daemon alt frame on cold restore and keeps its history', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true, + coldRestore: { scrollback: 'x', cwd: '/tmp' } + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('PREFIX-SCROLLBACK') + expect(writes.join('')).toContain('RESTORE-LIVE-STATE') + expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + expect(writes).toContain(`${RESET_GRAPHIC_RENDITION}PREFIX-SCROLLBACKRESTORE-LIVE-STATE`) + expect(writes).toContain(replayEpilogue(POST_REPLAY_MODE_RESET, 0)) + }) + + it('resizes the pane to the snapshot grid before replaying daemon snapshot bytes (bug #7279)', async () => { + // Why: the daemon serializes soft-wrapped lines flat, so reattach must resize xterm to the snapshot grid before writing, or rows rewrap wrong. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: '\x1b[?1004hrestored snapshot', + snapshotCols: 80, + snapshotRows: 24 + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] + } + } as StoreState + + // createPane opens the pane at 120x40, deliberately different from the daemon snapshot's 80x24 grid. + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + // Pane was resized to the snapshot grid before the snapshot bytes landed. + expect(pane.terminal.resize).toHaveBeenCalledWith(80, 24) + const resizeToSnapshotCall = pane.terminal.resize.mock.invocationCallOrder.find( + (_order, index) => { + const [cols, rows] = pane.terminal.resize.mock.calls[index] + return cols === 80 && rows === 24 + } + ) + const snapshotWriteCall = pane.terminal.write.mock.invocationCallOrder.find( + (_order, index) => + pane.terminal.write.mock.calls[index][0] === + `${RESET_GRAPHIC_RENDITION}\x1b[?1004hrestored snapshot` + ) + expect(resizeToSnapshotCall).toBeDefined() + expect(snapshotWriteCall).toBeDefined() + expect(resizeToSnapshotCall as number).toBeLessThan(snapshotWriteCall as number) + }) + + it('waits for a recovered destination fit before forwarding its grid or live output', async () => { + const { connectPanePty } = await import('./pty-connection') + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const transport = createMockTransport('tab-pty') + let deliverLiveData = (_data: string): void => {} + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (callbacks?.onData) { + deliverLiveData = callbacks.onData + } + return sessionId + ? { + id: sessionId, + snapshot: 'source-grid snapshot', + snapshotCols: 80, + snapshotRows: 24 + } + : null + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) + const signalPty = window.api.pty.signal as unknown as ReturnType + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + transport.resize.mockClear() + signalPty.mockClear() + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + + expect(transport.resize).not.toHaveBeenCalled() + expect(signalPty).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + + deliverLiveData('live-after-snapshot') + await flushAsyncTicks(4) + expect(writes.join('')).not.toContain('live-after-snapshot') + + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(12) + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(signalPty).toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + expect(writes.join('')).toContain('live-after-snapshot') + }) + + it('forwards the destination grid on reveal when the reattach fit was deferred by a display:none pane', async () => { + // Why: a restored floating-workspace pane reattaches while its panel is still closed, so the + // pane is display:none for the whole replay. The snapshot pins xterm to the PTY's grid, and the + // reattach fit is the only step that pushes the client grid back and signals SIGWINCH — losing + // it strands the PTY at the snapshot grid and the first reveal reflows the replay under a live TUI. + const { connectPanePty } = await import('./pty-connection') + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'source-grid snapshot', + snapshotCols: 80, + snapshotRows: 24 + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + let xtermContainerDisplay = 'none' + const xtermContainer = new EventTarget() as HTMLElement + Object.defineProperty(xtermContainer, 'parentElement', { value: null }) + Object.defineProperty(xtermContainer, 'ownerDocument', { + value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } } + }) + ;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + const signalPty = window.api.pty.signal as unknown as ReturnType + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }, + isVisibleRef: { current: false } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + transport.resize.mockClear() + signalPty.mockClear() + + // Reveal: the panel opens, the pane gains a box, and the first fit becomes measurable. + xtermContainerDisplay = 'block' + ;(deps.isVisibleRef as { current: boolean }).current = true + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(12) + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(signalPty).toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + }) + + it('suppresses the deferred reattach grid push when mobile claims the PTY while hidden', async () => { + // Why: the pre-check at reattach time cannot see a takeover that happens while the pane + // waits for a box, and this path calls transport.resize directly, bypassing + // forwardPtyResize's own suppression. + const { connectPanePty } = await import('./pty-connection') + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'source-grid snapshot', + snapshotCols: 80, + snapshotRows: 24 + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + let xtermContainerDisplay = 'none' + const xtermContainer = new EventTarget() as HTMLElement + Object.defineProperty(xtermContainer, 'parentElement', { value: null }) + Object.defineProperty(xtermContainer, 'ownerDocument', { + value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } } + }) + ;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + const signalPty = window.api.pty.signal as unknown as ReturnType + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }, + isVisibleRef: { current: false } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + transport.resize.mockClear() + signalPty.mockClear() + + try { + // Mobile takes the PTY while the pane is still hidden. + setFitOverride('tab-pty', 'mobile-fit', 49, 20) + + xtermContainerDisplay = 'block' + ;(deps.isVisibleRef as { current: boolean }).current = true + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(12) + + expect(transport.resize).not.toHaveBeenCalledWith(120, 40) + expect(signalPty).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + } finally { + setFitOverride('tab-pty', 'desktop-fit', 0, 0) + } + }) + + it('restores a pinned viewport only after a same-size reattach snapshot finishes parsing', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'same-size authoritative snapshot', + snapshotCols: 120, + snapshotRows: 40 + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + pane.terminal.buffer.active.baseY = 100 + pane.terminal.buffer.active.viewportY = 80 + markTerminalPinnedViewport(pane.terminal) + const { parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + + // Model xterm's native pinned state after clear+replay: the old line number is meaningless, but distance-from-bottom is preserved. + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 0 + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(180) + markTerminalFollowOutput(pane.terminal) + }) + + it('does not apply a queued reattach snapshot after the PTY is replaced', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + const reattachResult = createDeferred<{ + id: string + snapshot: string + }>() + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + replayCallback.current = callbacks?.onReplayData ?? null + return reattachResult.promise + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + replayCallback.current?.('blocking replay') + await flushAsyncTicks(12) + expect(writes).toEqual(['\x1b[?2026l\x1b[2J\x1b[3J\x1b[H']) + reattachResult.resolve({ id: 'tab-pty', snapshot: 'stale authoritative snapshot' }) + await flushAsyncTicks(12) + const resizeCallsBeforeReplacement = transport.resize.mock.calls.length + vi.mocked(transport.getPtyId).mockReturnValue('replacement-pty') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('stale authoritative snapshot') + expect(transport.resize).toHaveBeenCalledTimes(resizeCallsBeforeReplacement) + expect(window.api.pty.signal).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + }) + + it('does not apply a queued reattach snapshot after its PTY exits', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + const reattachResult = createDeferred<{ id: string; snapshot: string }>() + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + replayCallback.current = callbacks?.onReplayData ?? null + return reattachResult.promise + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + replayCallback.current?.('blocking replay') + await flushAsyncTicks(12) + reattachResult.resolve({ id: 'tab-pty', snapshot: 'dead authoritative snapshot' }) + await flushAsyncTicks(12) + const resizeCallsBeforeExit = transport.resize.mock.calls.length + vi.mocked(transport.getPtyId).mockReturnValue(null) + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('dead authoritative snapshot') + expect(transport.resize).toHaveBeenCalledTimes(resizeCallsBeforeExit) + expect(window.api.pty.signal).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + }) + + it('drops stale callbacks but delivers fresh replacement output after an old replay clear', async () => { + const { connectPanePty } = await import('./pty-connection') + const { getTerminalScrollIntentKind, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + const oldResult = createDeferred() + const oldCallbacks: { current: ConnectCallbacks | null } = { current: null } + const replacementCallbacks: { current: ConnectCallbacks | null } = { current: null } + let connectCount = 0 + transport.connect.mockImplementation( + async ({ callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + connectCount += 1 + if (connectCount === 1) { + oldCallbacks.current = callbacks ?? null + return oldResult.promise + } + vi.mocked(transport.getPtyId).mockReturnValue('replacement-pty') + replacementCallbacks.current = callbacks ?? null + return 'replacement-pty' + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 80 + pane.terminal.buffer.active.baseY = 100 + markTerminalPinnedViewport(pane.terminal) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + oldCallbacks.current?.onReplayData?.('old replay') + await flushAsyncTicks(8) + parseCallbacks.shift()?.() + await flushAsyncTicks(8) + oldCallbacks.current?.onError?.('SSH_SESSION_EXPIRED: tab-pty') + oldResult.resolve(undefined) + await flushAsyncTicks(20) + expect(replacementCallbacks.current).not.toBeNull() + + replacementCallbacks.current?.onData?.('B-PROMPT') + oldCallbacks.current?.onData?.('STALE-A') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(20) + + expect(writes).toContain('B-PROMPT') + expect(writes).not.toContain('STALE-A') + expect(pane.terminal.scrollToBottom).toHaveBeenCalled() + expect(getTerminalScrollIntentKind(pane.terminal)).toBe('followOutput') + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts deleted file mode 100644 index 549f5794a15..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts +++ /dev/null @@ -1,884 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - POST_REPLAY_DEAD_TUI_RESET, - POST_REPLAY_MODE_RESET, - POST_REPLAY_REATTACH_RESET, - RESET_GRAPHIC_RENDITION, - RELEASE_SYNCHRONIZED_OUTPUT -} from '../../../../shared/terminal-mode-reset-profiles' -import { replayEpilogue } from './pty-connection-test-replay-epilogue' -import { Terminal } from '@xterm/headless' -import { flushAsyncTicks, createDeferred, writeHeadlessTerminal } from './pty-connection-test-async' -import { createRect } from './pty-connection-test-dom' -import { - LEAF_1, - createMockTransport, - createPane, - captureCallbackTerminalWrites, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('clears the captured pen for a normal-buffer fallback reattach', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - const snapshot = 'ORCA-SGR-REPRO \x1b[1mBOLD-RUN-LEFT-OPEN\x1b[1;34H' - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { id: sessionId, snapshot } - } - return null - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] - }, - settings: { - ...mockStoreState.settings - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - `${RELEASE_SYNCHRONIZED_OUTPUT}${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`, - expect.any(Function) - ) - expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_GRAPHIC_RENDITION}${snapshot}`, - expect.any(Function) - ) - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_REATTACH_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_MODE_RESET, 0), - expect.any(Function) - ) - - const rendered = new Terminal({ cols: 40, rows: 6, allowProposedApi: true }) - try { - await writeHeadlessTerminal(rendered, '\x1b[1;44mDIRTY') - for (const [data] of pane.terminal.write.mock.calls) { - if (data) { - await writeHeadlessTerminal(rendered, data) - } - } - await writeHeadlessTerminal(rendered, 'PLAIN') - const line = rendered.buffer.active.getLine(rendered.buffer.active.baseY) - const plainColumn = line?.translateToString(true).indexOf('PLAIN') ?? -1 - - expect(line?.getCell(plainColumn)?.isBold()).toBe(0) - expect(line?.getCell(plainColumn)?.getFgColor()).toBe(-1) - expect(line?.getCell(plainColumn)?.getBgColor()).toBe(-1) - expect(rendered.buffer.active.getLine(5)?.getCell(39)?.getBgColor()).toBe(-1) - } finally { - rendered.dispose() - } - }) - - it('returns a shell-owned daemon snapshot to the normal buffer on reattach', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => - sessionId - ? { - id: sessionId, - snapshot: '\x1b[?1049h\x1b[?1003hSTALE-TUI', - isAlternateScreen: true, - snapshotTerminalOwner: 'shell' - } - : null - ) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } - } as StoreState - - const pane = createPane(1) - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) as never - ) - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), - expect.any(Function) - ) - }) - - it('drops a too-wide daemon alt frame and keeps the scrollback prefix', async () => { - // Why: fixed-grid alt rows clip at a narrower viewport until the owner repaints. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: sessionId, - snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', - snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', - snapshotFrameAnsi: 'ALT-FRAME-BODY', - snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', - snapshotCols: 200, - snapshotRows: 50, - isAlternateScreen: true - } - } - return null - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } - } as StoreState - - const pane = createPane(1) - pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) - expect(writes.join('')).toContain('PREFIX-SCROLLBACK') - expect(writes.join('')).toContain('RESTORE-LIVE-STATE') - expect(writes.join('')).not.toContain('ALT-FRAME-BODY') - }) - - it('keeps a daemon alt frame at its capture grid while the target grid is unknown', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => - sessionId - ? { - id: sessionId, - snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', - snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', - snapshotFrameAnsi: 'ALT-FRAME-BODY', - snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', - snapshotCols: 120, - snapshotRows: 40, - isAlternateScreen: true - } - : null - ) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } - } as StoreState - - const pane = createPane(1) - pane.container.getBoundingClientRect = vi.fn(() => createRect(0, 0)) - pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 1, rows: 1 })) - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) as never - ) - await flushAsyncTicks(20) - - const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) - expect(writes.join('')).toContain('PREFIX-SCROLLBACK') - expect(writes.join('')).toContain('ALT-FRAME-BODY') - expect(writes.filter((write) => write.includes('ALT-FRAME-BODY'))).toHaveLength(1) - }) - - it('falls back to the merged daemon snapshot when split metadata is incomplete', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => - sessionId - ? { - id: sessionId, - snapshot: 'LEGACY-MERGED-SNAPSHOT', - snapshotPrefixAnsi: 'INCOMPLETE-PREFIX', - snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', - snapshotCols: 200, - snapshotRows: 50, - isAlternateScreen: true - } - : null - ) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } - } as StoreState - - const pane = createPane(1) - pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) as never - ) - await flushAsyncTicks(20) - - const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) - expect(writes.join('')).toContain('LEGACY-MERGED-SNAPSHOT') - expect(writes.join('')).not.toContain('INCOMPLETE-PREFIX') - }) - - it('keeps a daemon alt frame when the pane is not narrower than the capture', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: sessionId, - snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', - snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', - snapshotFrameAnsi: 'ALT-FRAME-BODY', - snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', - snapshotCols: 120, - snapshotRows: 40, - isAlternateScreen: true - } - } - return null - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } - } as StoreState - - const pane = createPane(1) - pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect( - (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]).join('') - ).toContain('ALT-FRAME-BODY') - }) - - it('drops a too-wide daemon alt frame on cold restore and keeps its history', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: sessionId, - snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', - snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', - snapshotFrameAnsi: 'ALT-FRAME-BODY', - snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', - snapshotCols: 200, - snapshotRows: 50, - isAlternateScreen: true, - coldRestore: { scrollback: 'x', cwd: '/tmp' } - } - } - return null - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } - } as StoreState - - const pane = createPane(1) - pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) - expect(writes.join('')).toContain('PREFIX-SCROLLBACK') - expect(writes.join('')).toContain('RESTORE-LIVE-STATE') - expect(writes.join('')).not.toContain('ALT-FRAME-BODY') - expect(writes).toContain(`${RESET_GRAPHIC_RENDITION}PREFIX-SCROLLBACKRESTORE-LIVE-STATE`) - expect(writes).toContain(replayEpilogue(POST_REPLAY_MODE_RESET, 0)) - }) - - it('resizes the pane to the snapshot grid before replaying daemon snapshot bytes (bug #7279)', async () => { - // Why: the daemon serializes soft-wrapped lines flat, so reattach must resize xterm to the snapshot grid before writing, or rows rewrap wrong. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: sessionId, - snapshot: '\x1b[?1004hrestored snapshot', - snapshotCols: 80, - snapshotRows: 24 - } - } - return null - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] - } - } as StoreState - - // createPane opens the pane at 120x40, deliberately different from the daemon snapshot's 80x24 grid. - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - // Pane was resized to the snapshot grid before the snapshot bytes landed. - expect(pane.terminal.resize).toHaveBeenCalledWith(80, 24) - const resizeToSnapshotCall = pane.terminal.resize.mock.invocationCallOrder.find( - (_order, index) => { - const [cols, rows] = pane.terminal.resize.mock.calls[index] - return cols === 80 && rows === 24 - } - ) - const snapshotWriteCall = pane.terminal.write.mock.invocationCallOrder.find( - (_order, index) => - pane.terminal.write.mock.calls[index][0] === - `${RESET_GRAPHIC_RENDITION}\x1b[?1004hrestored snapshot` - ) - expect(resizeToSnapshotCall).toBeDefined() - expect(snapshotWriteCall).toBeDefined() - expect(resizeToSnapshotCall as number).toBeLessThan(snapshotWriteCall as number) - }) - - it('waits for a recovered destination fit before forwarding its grid or live output', async () => { - const { connectPanePty } = await import('./pty-connection') - const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') - const transport = createMockTransport('tab-pty') - let deliverLiveData = (_data: string): void => {} - transport.connect.mockImplementation( - async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { - if (callbacks?.onData) { - deliverLiveData = callbacks.onData - } - return sessionId - ? { - id: sessionId, - snapshot: 'source-grid snapshot', - snapshotCols: 80, - snapshotRows: 24 - } - : null - } - ) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } - } as StoreState - const pane = createPane(1) - pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never - const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) - const signalPty = window.api.pty.signal as unknown as ReturnType - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - transport.resize.mockClear() - signalPty.mockClear() - while (parseCallbacks.length > 0) { - parseCallbacks.shift()?.() - await flushAsyncTicks(2) - } - await flushAsyncTicks(8) - - expect(transport.resize).not.toHaveBeenCalled() - expect(signalPty).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') - - deliverLiveData('live-after-snapshot') - await flushAsyncTicks(4) - expect(writes.join('')).not.toContain('live-after-snapshot') - - pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never - safeFit(pane as never) - await flushAsyncTicks(12) - - expect(transport.resize).toHaveBeenCalledWith(120, 40) - expect(signalPty).toHaveBeenCalledWith('tab-pty', 'SIGWINCH') - expect(writes.join('')).toContain('live-after-snapshot') - }) - - it('forwards the destination grid on reveal when the reattach fit was deferred by a display:none pane', async () => { - // Why: a restored floating-workspace pane reattaches while its panel is still closed, so the - // pane is display:none for the whole replay. The snapshot pins xterm to the PTY's grid, and the - // reattach fit is the only step that pushes the client grid back and signals SIGWINCH — losing - // it strands the PTY at the snapshot grid and the first reveal reflows the replay under a live TUI. - const { connectPanePty } = await import('./pty-connection') - const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => - sessionId - ? { - id: sessionId, - snapshot: 'source-grid snapshot', - snapshotCols: 80, - snapshotRows: 24 - } - : null - ) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } - } as StoreState - const pane = createPane(1) - let xtermContainerDisplay = 'none' - const xtermContainer = new EventTarget() as HTMLElement - Object.defineProperty(xtermContainer, 'parentElement', { value: null }) - Object.defineProperty(xtermContainer, 'ownerDocument', { - value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } } - }) - ;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer - pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never - const signalPty = window.api.pty.signal as unknown as ReturnType - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }, - isVisibleRef: { current: false } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - transport.resize.mockClear() - signalPty.mockClear() - - // Reveal: the panel opens, the pane gains a box, and the first fit becomes measurable. - xtermContainerDisplay = 'block' - ;(deps.isVisibleRef as { current: boolean }).current = true - pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never - safeFit(pane as never) - await flushAsyncTicks(12) - - expect(transport.resize).toHaveBeenCalledWith(120, 40) - expect(signalPty).toHaveBeenCalledWith('tab-pty', 'SIGWINCH') - }) - - it('suppresses the deferred reattach grid push when mobile claims the PTY while hidden', async () => { - // Why: the pre-check at reattach time cannot see a takeover that happens while the pane - // waits for a box, and this path calls transport.resize directly, bypassing - // forwardPtyResize's own suppression. - const { connectPanePty } = await import('./pty-connection') - const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') - const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => - sessionId - ? { - id: sessionId, - snapshot: 'source-grid snapshot', - snapshotCols: 80, - snapshotRows: 24 - } - : null - ) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } - } as StoreState - const pane = createPane(1) - let xtermContainerDisplay = 'none' - const xtermContainer = new EventTarget() as HTMLElement - Object.defineProperty(xtermContainer, 'parentElement', { value: null }) - Object.defineProperty(xtermContainer, 'ownerDocument', { - value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } } - }) - ;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer - pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never - const signalPty = window.api.pty.signal as unknown as ReturnType - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }, - isVisibleRef: { current: false } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - transport.resize.mockClear() - signalPty.mockClear() - - try { - // Mobile takes the PTY while the pane is still hidden. - setFitOverride('tab-pty', 'mobile-fit', 49, 20) - - xtermContainerDisplay = 'block' - ;(deps.isVisibleRef as { current: boolean }).current = true - pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never - safeFit(pane as never) - await flushAsyncTicks(12) - - expect(transport.resize).not.toHaveBeenCalledWith(120, 40) - expect(signalPty).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') - } finally { - setFitOverride('tab-pty', 'desktop-fit', 0, 0) - } - }) - - it('restores a pinned viewport only after a same-size reattach snapshot finishes parsing', async () => { - const { connectPanePty } = await import('./pty-connection') - const { markTerminalFollowOutput, markTerminalPinnedViewport } = - await import('@/lib/pane-manager/terminal-scroll-intent') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => - sessionId - ? { - id: sessionId, - snapshot: 'same-size authoritative snapshot', - snapshotCols: 120, - snapshotRows: 40 - } - : null - ) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } - } as StoreState - const pane = createPane(1) - pane.terminal.buffer.active.baseY = 100 - pane.terminal.buffer.active.viewportY = 80 - markTerminalPinnedViewport(pane.terminal) - const { parseCallbacks } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() - - // Model xterm's native pinned state after clear+replay: the old line number is meaningless, but distance-from-bottom is preserved. - pane.terminal.buffer.active.baseY = 200 - pane.terminal.buffer.active.viewportY = 0 - while (parseCallbacks.length > 0) { - parseCallbacks.shift()?.() - await flushAsyncTicks(2) - } - await flushAsyncTicks(8) - - expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(180) - markTerminalFollowOutput(pane.terminal) - }) - - it('does not apply a queued reattach snapshot after the PTY is replaced', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - const reattachResult = createDeferred<{ - id: string - snapshot: string - }>() - const replayCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { - if (!sessionId) { - return null - } - replayCallback.current = callbacks?.onReplayData ?? null - return reattachResult.promise - } - ) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(8) - replayCallback.current?.('blocking replay') - await flushAsyncTicks(12) - expect(writes).toEqual(['\x1b[?2026l\x1b[2J\x1b[3J\x1b[H']) - reattachResult.resolve({ id: 'tab-pty', snapshot: 'stale authoritative snapshot' }) - await flushAsyncTicks(12) - const resizeCallsBeforeReplacement = transport.resize.mock.calls.length - vi.mocked(transport.getPtyId).mockReturnValue('replacement-pty') - while (parseCallbacks.length > 0) { - parseCallbacks.shift()?.() - await flushAsyncTicks(4) - } - await flushAsyncTicks(12) - - expect(writes).not.toContain('stale authoritative snapshot') - expect(transport.resize).toHaveBeenCalledTimes(resizeCallsBeforeReplacement) - expect(window.api.pty.signal).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') - }) - - it('does not apply a queued reattach snapshot after its PTY exits', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - const reattachResult = createDeferred<{ id: string; snapshot: string }>() - const replayCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { - replayCallback.current = callbacks?.onReplayData ?? null - return reattachResult.promise - } - ) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(8) - replayCallback.current?.('blocking replay') - await flushAsyncTicks(12) - reattachResult.resolve({ id: 'tab-pty', snapshot: 'dead authoritative snapshot' }) - await flushAsyncTicks(12) - const resizeCallsBeforeExit = transport.resize.mock.calls.length - vi.mocked(transport.getPtyId).mockReturnValue(null) - while (parseCallbacks.length > 0) { - parseCallbacks.shift()?.() - await flushAsyncTicks(4) - } - await flushAsyncTicks(12) - - expect(writes).not.toContain('dead authoritative snapshot') - expect(transport.resize).toHaveBeenCalledTimes(resizeCallsBeforeExit) - expect(window.api.pty.signal).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') - expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') - }) - - it('drops stale callbacks but delivers fresh replacement output after an old replay clear', async () => { - const { connectPanePty } = await import('./pty-connection') - const { getTerminalScrollIntentKind, markTerminalPinnedViewport } = - await import('@/lib/pane-manager/terminal-scroll-intent') - const transport = createMockTransport('tab-pty') - const oldResult = createDeferred() - const oldCallbacks: { current: ConnectCallbacks | null } = { current: null } - const replacementCallbacks: { current: ConnectCallbacks | null } = { current: null } - let connectCount = 0 - transport.connect.mockImplementation( - async ({ callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { - connectCount += 1 - if (connectCount === 1) { - oldCallbacks.current = callbacks ?? null - return oldResult.promise - } - vi.mocked(transport.getPtyId).mockReturnValue('replacement-pty') - replacementCallbacks.current = callbacks ?? null - return 'replacement-pty' - } - ) - transportFactoryQueue.push(transport) - const pane = createPane(1) - pane.terminal.buffer.active.viewportY = 80 - pane.terminal.buffer.active.baseY = 100 - markTerminalPinnedViewport(pane.terminal) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(8) - oldCallbacks.current?.onReplayData?.('old replay') - await flushAsyncTicks(8) - parseCallbacks.shift()?.() - await flushAsyncTicks(8) - oldCallbacks.current?.onError?.('SSH_SESSION_EXPIRED: tab-pty') - oldResult.resolve(undefined) - await flushAsyncTicks(20) - expect(replacementCallbacks.current).not.toBeNull() - - replacementCallbacks.current?.onData?.('B-PROMPT') - oldCallbacks.current?.onData?.('STALE-A') - while (parseCallbacks.length > 0) { - parseCallbacks.shift()?.() - await flushAsyncTicks(4) - } - await flushAsyncTicks(20) - - expect(writes).toContain('B-PROMPT') - expect(writes).not.toContain('STALE-A') - expect(pane.terminal.scrollToBottom).toHaveBeenCalled() - expect(getTerminalScrollIntentKind(pane.terminal)).toBe('followOutput') - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.spec.ts new file mode 100644 index 00000000000..48c1825bb7b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.spec.ts @@ -0,0 +1,460 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + captureCallbackTerminalWrites, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionDeferredReattachLiveOutputTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('does not consume the sleeping record when daemon reattach returns a live snapshot', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: 'live-snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(transport.sendInput).not.toHaveBeenCalled() + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + }) + + it('does not resume the provider session when daemon reattach returns a live snapshot', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: 'live-snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + state: 'working', + prompt: 'finish the task', + agentType: 'codex', + paneKey, + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + providerSession: { key: 'session_id', id: 'codex-session-1' } + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(pane.terminal.write).toHaveBeenCalledWith('live-snapshot', expect.any(Function)) + expect(transport.sendInput).not.toHaveBeenCalled() + }) + + it.each([ + { kind: 'covered backlog', snapshot: 'startup\r\n', seq: 9, count: 1 }, + { kind: 'legacy unsequenced snapshot', snapshot: 'startup\r\n', seq: undefined, count: 2 }, + { kind: 'blank snapshot', snapshot: '\x1b[2J', seq: 9, count: 1 } + ])( + 'preserves output while reconciling $kind on daemon adoption', + async ({ snapshot, seq, count }) => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation( + async ({ + sessionId, + callbacks + }: { + sessionId?: string + callbacks?: ConnectCallbacks + }) => { + callbacks?.onData?.('startup\r\n', { seq: 9, rawLength: 9 }) + callbacks?.onData?.('new output\r\n', { seq: 21, rawLength: 12 }) + return { id: sessionId, snapshot, snapshotSeq: seq } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + isVisibleRef: { current: true }, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + for (let step = 0; step < 40; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + expect(writes.join('').match(/startup/g)).toHaveLength(count) + expect(writes.join('')).toContain('new output') + } + ) + + it('drains live bytes after transport confirms an explicit reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const { deliverTerminalDataWithDeferredCredit } = + await import('@/lib/pane-manager/terminal-delivery-credit') + const transport = createMockTransport('tab-pty') + const acknowledgeLiveFrame = vi.fn() + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + // Why: the real dispatcher drains post-snapshot bytes as soon as spawn IPC resolves, before connect() returns. + callbacks?.onReattachDetermined?.() + deliverTerminalDataWithDeferredCredit(acknowledgeLiveFrame, () => { + callbacks?.onData?.('post-snapshot-live') + }) + return { id: sessionId, snapshot: 'authoritative-snapshot' } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + + const snapshotIndex = writes.indexOf(`${RESET_GRAPHIC_RENDITION}authoritative-snapshot`) + expect(snapshotIndex).toBeGreaterThanOrEqual(0) + expect(writes).not.toContain('post-snapshot-live') + for (let step = 0; step < 40; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + const liveIndex = writes.indexOf('post-snapshot-live') + expect(liveIndex).toBeGreaterThan(snapshotIndex) + expect(acknowledgeLiveFrame).toHaveBeenCalledOnce() + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'tab-pty') + }) + + it('re-enforces follow intent after deferred reattach live output parses', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + callbacks?.onData?.('post-snapshot-live') + return { id: sessionId, snapshot: 'authoritative-snapshot' } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.buffer.active.baseY = 100 + pane.terminal.buffer.active.viewportY = 100 + markTerminalFollowOutput(pane.terminal) + const parseCallbacks: (() => void)[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + parseCallbacks.push(() => { + if (data === 'post-snapshot-live') { + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 100 + } + callback?.() + }) + }) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + for (let index = 0; index < 30; index += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + if (parseCallbacks.length === 0 && index > 5) { + break + } + } + + expect(pane.terminal.buffer.active.viewportY).toBe(200) + }) + + it('does not steal a newer user pin while deferred reattach output settles', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + callbacks?.onData?.('post-snapshot-live') + return { id: sessionId, snapshot: 'authoritative-snapshot' } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.buffer.active.baseY = 100 + pane.terminal.buffer.active.viewportY = 100 + markTerminalFollowOutput(pane.terminal) + const parseCallbacks: { data: string; run: () => void }[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + parseCallbacks.push({ + data, + run: () => { + if (data === 'post-snapshot-live') { + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 100 + } + callback?.() + } + }) + }) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + for (let index = 0; index < 40; index += 1) { + const pending = parseCallbacks.shift() + pending?.run() + await flushAsyncTicks(4) + if (pending?.data === 'post-snapshot-live') { + pane.terminal.buffer.active.viewportY = 150 + markTerminalPinnedViewport(pane.terminal) + } + if (parseCallbacks.length === 0 && index > 8) { + break + } + } + + expect(pane.terminal.buffer.active.viewportY).toBe(150) + }) + + it('does not enforce a deferred viewport after the pane becomes hidden', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + callbacks?.onData?.('post-snapshot-live') + return { id: sessionId, snapshot: 'authoritative-snapshot' } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.buffer.active.baseY = 100 + pane.terminal.buffer.active.viewportY = 100 + markTerminalFollowOutput(pane.terminal) + const parseCallbacks: { data: string; run: () => void }[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + parseCallbacks.push({ + data, + run: () => { + if (data === 'post-snapshot-live') { + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 100 + } + callback?.() + } + }) + }) + const deps = createDeps({ + isVisibleRef: { current: true }, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + for (let index = 0; index < 40; index += 1) { + const pending = parseCallbacks.shift() + pending?.run() + await flushAsyncTicks(4) + if (pending?.data === 'post-snapshot-live') { + ;(deps.isVisibleRef as { current: boolean }).current = false + } + if (parseCallbacks.length === 0 && index > 8) { + break + } + } + + expect(pane.terminal.buffer.active.viewportY).toBe(100) + }) + + it('does not fresh-spawn after a dead deferred session delivers its buffered exit', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return 'unexpected-fresh-pty' + } + callbacks?.onData?.('dead-session-final-frame') + return { id: sessionId, exitedBeforeAttach: true } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(writes).toContain('dead-session-final-frame') + expect(deps.updateTabPtyId).not.toHaveBeenCalled() + }) + + // Why: Phase 6 deleted the hidden-skip grammar — every hidden chunk rides the background scheduler, none content-scanned. + it('queues hidden PTY bytes on the background scheduler without per-chunk scanning', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() + try { + const hiddenChunks = [ + 'plain hidden text\r\n', + '\x1b[2J\x1b[Hcontrol redraw\r\n', + '\x1b[2J\x1b[H╭ table 😀 ╮\r\n', + '\x1b[?2026h| Sam Syntax | 😀 |\r\n\x1b[?2026l', + '\x1b[?2026h\x1b[6n' + ] + for (const chunk of hiddenChunks) { + capturedDataCallback.current?.(chunk) + } + + // Background path defers writes; nothing is written synchronously. + expect(pane.terminal.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(50) + // The drain may coalesce queued chunks into one write — assert content. + const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + for (const chunk of hiddenChunks) { + expect(written).toContain(chunk) + } + // No model restore is latched for bounded hidden output. + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts deleted file mode 100644 index ab4a3cd1414..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts +++ /dev/null @@ -1,543 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - LEAF_1, - createMockTransport, - createPane, - captureCallbackTerminalWrites, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('does not consume the sleeping record when daemon reattach returns a live snapshot', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { id: sessionId, snapshot: 'live-snapshot' } - } - return null - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(transport.sendInput).not.toHaveBeenCalled() - expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() - }) - - it('does not resume the provider session when daemon reattach returns a live snapshot', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { id: sessionId, snapshot: 'live-snapshot' } - } - return null - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - agentStatusByPaneKey: { - [paneKey]: { - state: 'working', - prompt: 'finish the task', - agentType: 'codex', - paneKey, - updatedAt: 1, - stateStartedAt: 1, - stateHistory: [], - providerSession: { key: 'session_id', id: 'codex-session-1' } - } - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(pane.terminal.write).toHaveBeenCalledWith('live-snapshot', expect.any(Function)) - expect(transport.sendInput).not.toHaveBeenCalled() - }) - - it.each([ - { kind: 'covered backlog', snapshot: 'startup\r\n', seq: 9, count: 1 }, - { kind: 'legacy unsequenced snapshot', snapshot: 'startup\r\n', seq: undefined, count: 2 }, - { kind: 'blank snapshot', snapshot: '\x1b[2J', seq: 9, count: 1 } - ])( - 'preserves output while reconciling $kind on daemon adoption', - async ({ snapshot, seq, count }) => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation( - async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { - callbacks?.onData?.('startup\r\n', { seq: 9, rawLength: 9 }) - callbacks?.onData?.('new output\r\n', { seq: 21, rawLength: 12 }) - return { id: sessionId, snapshot, snapshotSeq: seq } - } - ) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ - isVisibleRef: { current: true }, - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - for (let step = 0; step < 40; step += 1) { - parseCallbacks.shift()?.() - await flushAsyncTicks(2) - } - expect(writes.join('').match(/startup/g)).toHaveLength(count) - expect(writes.join('')).toContain('new output') - } - ) - - it('drains live bytes after transport confirms an explicit reattach', async () => { - const { connectPanePty } = await import('./pty-connection') - const { deliverTerminalDataWithDeferredCredit } = - await import('@/lib/pane-manager/terminal-delivery-credit') - const transport = createMockTransport('tab-pty') - const acknowledgeLiveFrame = vi.fn() - transport.connect.mockImplementation( - async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { - if (!sessionId) { - return null - } - // Why: the real dispatcher drains post-snapshot bytes as soon as spawn IPC resolves, before connect() returns. - callbacks?.onReattachDetermined?.() - deliverTerminalDataWithDeferredCredit(acknowledgeLiveFrame, () => { - callbacks?.onData?.('post-snapshot-live') - }) - return { id: sessionId, snapshot: 'authoritative-snapshot' } - } - ) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - - const snapshotIndex = writes.indexOf(`${RESET_GRAPHIC_RENDITION}authoritative-snapshot`) - expect(snapshotIndex).toBeGreaterThanOrEqual(0) - expect(writes).not.toContain('post-snapshot-live') - for (let step = 0; step < 40; step += 1) { - parseCallbacks.shift()?.() - await flushAsyncTicks(2) - } - const liveIndex = writes.indexOf('post-snapshot-live') - expect(liveIndex).toBeGreaterThan(snapshotIndex) - expect(acknowledgeLiveFrame).toHaveBeenCalledOnce() - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'tab-pty') - }) - - it('re-enforces follow intent after deferred reattach live output parses', async () => { - const { connectPanePty } = await import('./pty-connection') - const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation( - async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { - if (!sessionId) { - return null - } - callbacks?.onData?.('post-snapshot-live') - return { id: sessionId, snapshot: 'authoritative-snapshot' } - } - ) - transportFactoryQueue.push(transport) - const pane = createPane(1) - pane.terminal.buffer.active.baseY = 100 - pane.terminal.buffer.active.viewportY = 100 - markTerminalFollowOutput(pane.terminal) - const parseCallbacks: (() => void)[] = [] - pane.terminal.write = vi.fn((data: string, callback?: () => void) => { - parseCallbacks.push(() => { - if (data === 'post-snapshot-live') { - pane.terminal.buffer.active.baseY = 200 - pane.terminal.buffer.active.viewportY = 100 - } - callback?.() - }) - }) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - for (let index = 0; index < 30; index += 1) { - parseCallbacks.shift()?.() - await flushAsyncTicks(4) - if (parseCallbacks.length === 0 && index > 5) { - break - } - } - - expect(pane.terminal.buffer.active.viewportY).toBe(200) - }) - - it('does not steal a newer user pin while deferred reattach output settles', async () => { - const { connectPanePty } = await import('./pty-connection') - const { markTerminalFollowOutput, markTerminalPinnedViewport } = - await import('@/lib/pane-manager/terminal-scroll-intent') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation( - async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { - if (!sessionId) { - return null - } - callbacks?.onData?.('post-snapshot-live') - return { id: sessionId, snapshot: 'authoritative-snapshot' } - } - ) - transportFactoryQueue.push(transport) - const pane = createPane(1) - pane.terminal.buffer.active.baseY = 100 - pane.terminal.buffer.active.viewportY = 100 - markTerminalFollowOutput(pane.terminal) - const parseCallbacks: { data: string; run: () => void }[] = [] - pane.terminal.write = vi.fn((data: string, callback?: () => void) => { - parseCallbacks.push({ - data, - run: () => { - if (data === 'post-snapshot-live') { - pane.terminal.buffer.active.baseY = 200 - pane.terminal.buffer.active.viewportY = 100 - } - callback?.() - } - }) - }) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - for (let index = 0; index < 40; index += 1) { - const pending = parseCallbacks.shift() - pending?.run() - await flushAsyncTicks(4) - if (pending?.data === 'post-snapshot-live') { - pane.terminal.buffer.active.viewportY = 150 - markTerminalPinnedViewport(pane.terminal) - } - if (parseCallbacks.length === 0 && index > 8) { - break - } - } - - expect(pane.terminal.buffer.active.viewportY).toBe(150) - }) - - it('does not enforce a deferred viewport after the pane becomes hidden', async () => { - const { connectPanePty } = await import('./pty-connection') - const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation( - async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { - if (!sessionId) { - return null - } - callbacks?.onData?.('post-snapshot-live') - return { id: sessionId, snapshot: 'authoritative-snapshot' } - } - ) - transportFactoryQueue.push(transport) - const pane = createPane(1) - pane.terminal.buffer.active.baseY = 100 - pane.terminal.buffer.active.viewportY = 100 - markTerminalFollowOutput(pane.terminal) - const parseCallbacks: { data: string; run: () => void }[] = [] - pane.terminal.write = vi.fn((data: string, callback?: () => void) => { - parseCallbacks.push({ - data, - run: () => { - if (data === 'post-snapshot-live') { - pane.terminal.buffer.active.baseY = 200 - pane.terminal.buffer.active.viewportY = 100 - } - callback?.() - } - }) - }) - const deps = createDeps({ - isVisibleRef: { current: true }, - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - for (let index = 0; index < 40; index += 1) { - const pending = parseCallbacks.shift() - pending?.run() - await flushAsyncTicks(4) - if (pending?.data === 'post-snapshot-live') { - ;(deps.isVisibleRef as { current: boolean }).current = false - } - if (parseCallbacks.length === 0 && index > 8) { - break - } - } - - expect(pane.terminal.buffer.active.viewportY).toBe(100) - }) - - it('does not fresh-spawn after a dead deferred session delivers its buffered exit', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation( - async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { - if (!sessionId) { - return 'unexpected-fresh-pty' - } - callbacks?.onData?.('dead-session-final-frame') - return { id: sessionId, exitedBeforeAttach: true } - } - ) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const { writes } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - - expect(transport.connect).toHaveBeenCalledTimes(1) - expect(writes).toContain('dead-session-final-frame') - expect(deps.updateTabPtyId).not.toHaveBeenCalled() - }) - - // Why: Phase 6 deleted the hidden-skip grammar — every hidden chunk rides the background scheduler, none content-scanned. - it('queues hidden PTY bytes on the background scheduler without per-chunk scanning', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - try { - const hiddenChunks = [ - 'plain hidden text\r\n', - '\x1b[2J\x1b[Hcontrol redraw\r\n', - '\x1b[2J\x1b[H╭ table 😀 ╮\r\n', - '\x1b[?2026h| Sam Syntax | 😀 |\r\n\x1b[?2026l', - '\x1b[?2026h\x1b[6n' - ] - for (const chunk of hiddenChunks) { - capturedDataCallback.current?.(chunk) - } - - // Background path defers writes; nothing is written synchronously. - expect(pane.terminal.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(50) - // The drain may coalesce queued chunks into one write — assert content. - const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') - for (const chunk of hiddenChunks) { - expect(written).toContain(chunk) - } - // No model restore is latched for bounded hidden output. - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts new file mode 100644 index 00000000000..c1495a3ee63 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.spec.ts @@ -0,0 +1,279 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + LEAF_1, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionDeliberateSleepGuardTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('deliberate sleep keeps mounted panes cold', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + const { clearWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + clearWorktreeSleepIntent('wt-1') + await restoreTerminalTestGlobals() + }) + + // Why: manual sleep keeps tab.ptyId as a wake hint, so a remount would take the + // REATTACH arm and the daemon would respawn a shell for the dead id (#10205). + it('does not reattach a slept pane through its retained session id', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-other', + tabsByWorktree: { 'wt-1': [{ id: 'tab-slept', ptyId: 'wt-1@@dead' }] } + } + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-slept', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, + isVisibleRef: { current: false } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + }) + + it('does not fresh-spawn a slept pane that has no session id', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { ...mockStoreState, activeWorktreeId: 'wt-other' } + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ tabId: 'tab-slept-bare', isVisibleRef: { current: false } }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + }) + + it('connects a waiting pane once the workspace is woken', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-other', + tabsByWorktree: { 'wt-1': [{ id: 'tab-woken', ptyId: 'wt-1@@dead' }] } + } + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-woken', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, + isVisibleRef: { current: false } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + + it('does not connect a waiting pane whose tab was remounted by the wake', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-other', + tabsByWorktree: { 'wt-1': [{ id: 'tab-remounted', ptyId: 'wt-1@@dead', generation: 0 }] } + } + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-remounted', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, + isVisibleRef: { current: false } + }) + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + // Why: activation bumps generation in the same set() that precedes the clear. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-remounted', ptyId: 'wt-1@@dead', generation: 1 }] } + } + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + }) + + it('resumes a waiting pane mounted under a unified tab id', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-other', + tabsByWorktree: { 'wt-1': [{ id: 'tab-entity', ptyId: null, generation: 3 }] }, + getTab: (id: string) => + id === 'unified-1' ? { id, contentType: 'terminal', entityId: 'tab-entity' } : null + } as never + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ tabId: 'unified-1', isVisibleRef: { current: false } }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + + it('re-arms after a wake so a second sleep can hold the pane again', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + let releaseCwd: (cwd: string) => void = () => {} + const cwdPromise = new Promise((resolve) => { + releaseCwd = resolve + }) + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-resleep', + isVisibleRef: { current: false }, + cwdPromise + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + // Wake: the pane leaves the sleep gate and parks on the cwd gate. + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + // Sleep again before the cwd settles, then wake again. + markWorktreeSleepIntent('wt-1') + releaseCwd('/cwd') + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + + it('drops the wake listener when a waiting pane is disposed', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ tabId: 'tab-disposed', isVisibleRef: { current: false } }) + + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) + await flushAsyncTicks() + binding.dispose() + // Why: the connect body already refuses a disposed session, so prove the + // listener itself is gone: a wake after dispose reaches no subscriber. + const wakeCalls: number[] = [] + const { onWorktreeSleepIntentCleared } = await import('@/lib/worktree-sleep-intent') + onWorktreeSleepIntentCleared('wt-1', () => wakeCalls.push(1)) + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + expect(wakeCalls).toHaveLength(1) + }) + + it('still connects a slept pane that carries a queued startup', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-slept-startup', + isVisibleRef: { current: false }, + startup: { command: 'printf wake', launchAgent: undefined } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + + it('connects normally once the marker is released', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId: 'tab-awake', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@live' }, + isVisibleRef: { current: false } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts deleted file mode 100644 index 71cd170be08..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - createMockTransport, - createPane, - createManager, - LEAF_1, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('deliberate sleep keeps mounted panes cold', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - const { clearWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') - clearWorktreeSleepIntent('wt-1') - await restoreTerminalTestGlobals() - }) - - // Why: manual sleep keeps tab.ptyId as a wake hint, so a remount would take the - // REATTACH arm and the daemon would respawn a shell for the dead id (#10205). - it('does not reattach a slept pane through its retained session id', async () => { - const { connectPanePty } = await import('./pty-connection') - const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - activeWorktreeId: 'wt-other', - tabsByWorktree: { 'wt-1': [{ id: 'tab-slept', ptyId: 'wt-1@@dead' }] } - } - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ - tabId: 'tab-slept', - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, - isVisibleRef: { current: false } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).not.toHaveBeenCalled() - }) - - it('does not fresh-spawn a slept pane that has no session id', async () => { - const { connectPanePty } = await import('./pty-connection') - const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { ...mockStoreState, activeWorktreeId: 'wt-other' } - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ tabId: 'tab-slept-bare', isVisibleRef: { current: false } }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).not.toHaveBeenCalled() - }) - - it('connects a waiting pane once the workspace is woken', async () => { - const { connectPanePty } = await import('./pty-connection') - const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = - await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - activeWorktreeId: 'wt-other', - tabsByWorktree: { 'wt-1': [{ id: 'tab-woken', ptyId: 'wt-1@@dead' }] } - } - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ - tabId: 'tab-woken', - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, - isVisibleRef: { current: false } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - expect(transport.connect).not.toHaveBeenCalled() - - clearWorktreeSleepIntent('wt-1') - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - }) - - it('does not connect a waiting pane whose tab was remounted by the wake', async () => { - const { connectPanePty } = await import('./pty-connection') - const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = - await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - activeWorktreeId: 'wt-other', - tabsByWorktree: { 'wt-1': [{ id: 'tab-remounted', ptyId: 'wt-1@@dead', generation: 0 }] } - } - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ - tabId: 'tab-remounted', - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, - isVisibleRef: { current: false } - }) - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - // Why: activation bumps generation in the same set() that precedes the clear. - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-remounted', ptyId: 'wt-1@@dead', generation: 1 }] } - } - clearWorktreeSleepIntent('wt-1') - await flushAsyncTicks() - - expect(transport.connect).not.toHaveBeenCalled() - }) - - it('resumes a waiting pane mounted under a unified tab id', async () => { - const { connectPanePty } = await import('./pty-connection') - const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = - await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - activeWorktreeId: 'wt-other', - tabsByWorktree: { 'wt-1': [{ id: 'tab-entity', ptyId: null, generation: 3 }] }, - getTab: (id: string) => - id === 'unified-1' ? { id, contentType: 'terminal', entityId: 'tab-entity' } : null - } as never - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ tabId: 'unified-1', isVisibleRef: { current: false } }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - expect(transport.connect).not.toHaveBeenCalled() - - clearWorktreeSleepIntent('wt-1') - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - }) - - it('re-arms after a wake so a second sleep can hold the pane again', async () => { - const { connectPanePty } = await import('./pty-connection') - const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = - await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - let releaseCwd: (cwd: string) => void = () => {} - const cwdPromise = new Promise((resolve) => { - releaseCwd = resolve - }) - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ tabId: 'tab-resleep', isVisibleRef: { current: false }, cwdPromise }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - // Wake: the pane leaves the sleep gate and parks on the cwd gate. - clearWorktreeSleepIntent('wt-1') - await flushAsyncTicks() - // Sleep again before the cwd settles, then wake again. - markWorktreeSleepIntent('wt-1') - releaseCwd('/cwd') - await flushAsyncTicks() - expect(transport.connect).not.toHaveBeenCalled() - clearWorktreeSleepIntent('wt-1') - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - }) - - it('drops the wake listener when a waiting pane is disposed', async () => { - const { connectPanePty } = await import('./pty-connection') - const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = - await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ tabId: 'tab-disposed', isVisibleRef: { current: false } }) - - const binding = connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - binding.dispose() - // Why: the connect body already refuses a disposed session, so prove the - // listener itself is gone: a wake after dispose reaches no subscriber. - const wakeCalls: number[] = [] - const { onWorktreeSleepIntentCleared } = await import('@/lib/worktree-sleep-intent') - onWorktreeSleepIntentCleared('wt-1', () => wakeCalls.push(1)) - clearWorktreeSleepIntent('wt-1') - await flushAsyncTicks() - - expect(transport.connect).not.toHaveBeenCalled() - expect(wakeCalls).toHaveLength(1) - }) - - it('still connects a slept pane that carries a queued startup', async () => { - const { connectPanePty } = await import('./pty-connection') - const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - markWorktreeSleepIntent('wt-1') - const deps = createDeps({ - tabId: 'tab-slept-startup', - isVisibleRef: { current: false }, - startup: { command: 'printf wake', launchAgent: undefined } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - }) - - it('connects normally once the marker is released', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const deps = createDeps({ - tabId: 'tab-awake', - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@live' }, - isVisibleRef: { current: false } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.spec.ts new file mode 100644 index 00000000000..cb827213299 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.spec.ts @@ -0,0 +1,555 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + VISIBLE_PTY_SETTLE_MS, + WRAPPER_RESOLVE_RETRY_MS, + SECOND_WRAPPER_RETRY_MS +} from './pty-connection-test-constants' +import { sendTerminalInputThroughPane } from './pty-connection-test-dom' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { + resolveMockPaneWindowsShiftEnterEncoding, + type StoreState +} from './pty-connection-test-store-state' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionForegroundAgentRoutingTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('drops agent status without retaining when OSC 133 reports the command finished', async () => { + const { connectPanePty } = await import('./pty-connection') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport() + let currentPtyId: string | null = null + vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + currentPtyId = 'pty-local-1' + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-local-1' + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ isVisibleRef: { current: false } }) + + connectPanePty(pane as never, manager as never, deps as never) + + capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') + await flushAsyncTicks() + + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() + }) + + it('clears pre-hook launch config when an Orca-started command exits', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport() + let currentPtyId: string | null = null + vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + currentPtyId = 'pty-local-1' + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-local-1' + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + startup: { + command: "codex '--dangerously-bypass-approvals-and-sandbox'", + launchConfig: { + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: {} + }, + launchAgent: 'codex' + }, + restoredPtyIdByLeafId: {} + }) as never + ) + + expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith( + paneKey, + { + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: {} + }, + expect.objectContaining({ agentType: 'codex' }) + ) + capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') + await flushAsyncTicks() + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() + + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + }) + + it('routes a manually typed Droid only after foreground enrichment confirms it', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const ptyId = 'pty-manually-typed-droid' + const tabId = 'tab-manually-typed-droid' + const foregroundResults = ['powershell.exe', 'droid'] + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async (id: string) => + id === ptyId ? (foregroundResults.shift() ?? 'droid') : null + ) + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey(tabId, LEAF_1) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + + sendTerminalInputThroughPane(pane, 'droid\r') + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + dataCallbackRef.current?.('\x1b]133;C\x07') + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + await vi.advanceTimersByTimeAsync(350) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + await vi.advanceTimersByTimeAsync(1200) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + }) + }) + + it('confirms a manually typed Droid without OSC command boundaries', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + const pane = createPane(1) + const ptyId = 'pty-manual-droid-no-osc' + const tabId = 'tab-manual-droid-no-osc' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + + sendTerminalInputThroughPane(pane, 'droid\r') + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + await vi.advanceTimersByTimeAsync(350) + + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('confirms an Orca-launched Droid fresh spawn in a no-OSC shell (Git Bash)', async () => { + // Why: no-OSC shells (Git Bash/cmd) emit no command boundary, so without a fresh-spawn sample the pane never earns routing trust and Shift+Enter regresses to Esc+CR (#7620). + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('droid') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + const pane = createPane(1) + const ptyId = 'pty-launched-droid-no-osc' + const tabId = 'tab-launched-droid-no-osc' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + tabId, + startup: { command: 'droid', launchAgent: 'droid' } + }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + // The transport reports the fresh spawn; no OSC 133, no typed inference. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Captured transport options expose onPtySpawn as an optional PTY-id callback. + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((id: string) => void) + | undefined + expect(onPtySpawn).toBeTypeOf('function') + onPtySpawn?.(ptyId) + await vi.advanceTimersByTimeAsync( + VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + ) + await flushAsyncTicks() + + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('trusts a launched Droid whose no-OSC boot only becomes foreground after retries', async () => { + // Why: the confirmation ladder must span Droid's boot — the shell is still foreground on the first read(s) before Droid takes over. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const foregroundResults = ['bash.exe', 'bash.exe', 'droid'] + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation( + async () => foregroundResults.shift() ?? 'droid' + ) + const pane = createPane(1) + const ptyId = 'pty-launched-droid-slow-boot' + const tabId = 'tab-launched-droid-slow-boot' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, startup: { command: 'droid', launchAgent: 'droid' } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Captured transport options expose onPtySpawn as an optional PTY-id callback. + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((id: string) => void) + | undefined + onPtySpawn?.(ptyId) + await vi.advanceTimersByTimeAsync( + VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + ) + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('stays recoverable when a launched Droid outlasts the fresh-spawn confirmation window', async () => { + // Why: a missed ladder (slow boot) must stay recoverable — latching a shell-confirm would clear launch identity and poison Shift+Enter for the session. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + let foreground = 'bash.exe' + // Why both: the pane's process monitor reads the same foreground the confirm read sees. + vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) + const pane = createPane(1) + const ptyId = 'pty-launched-droid-slow' + const tabId = 'tab-launched-droid-slow' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, startup: { command: 'droid', launchAgent: 'droid' } }) as never + ) as unknown as { sampleForegroundAgentOnFocus: () => void } + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Captured transport options expose onPtySpawn as an optional PTY-id callback. + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((id: string) => void) + | undefined + onPtySpawn?.(ptyId) + // Whole ladder elapses while the shell is still foreground (Droid not up yet). + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + // Benign miss: shell never latched as foreground; encoding still safe fallback. + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + // Droid finally boots and a focus event re-samples: trust is recoverable. + foreground = 'droid' + binding.sampleForegroundAgentOnFocus() + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('confirms a delayed Claude from fresh startup output after the first read window expires', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + let foreground = 'sleep' + vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) + const tabId = 'tab-delayed-claude' + const ptyId = 'pty-delayed-claude' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + const binding = connectPanePty( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The pane fixture implements the terminal APIs exercised by this connection test. + createPane(1) as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The manager fixture supplies this single pane and its connection callbacks. + createManager(1) as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection dependency fixture supplies all APIs used by the test with the explicit startup override. + createDeps({ tabId, startup: { command: 'sleep 30 && claude' } }) as never + ) + await vi.advanceTimersByTimeAsync(30_000) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]?.agent).not.toBe('claude') + + foreground = 'claude' + const onTitleChange = createdTransportOptions[0]?.onTitleChange + if (typeof onTitleChange !== 'function') { + throw new Error('Missing live title consumer') + } + // Captured with capture-agent-pty-transcript.mjs from a real delayed Claude launch. + onTitleChange('✳ Claude Code', '✳ Claude Code') + await vi.advanceTimersByTimeAsync(350) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toMatchObject({ + agent: 'claude', + agentEvidence: 'process-read', + routingTrusted: true + }) + binding.dispose() + }) + + it('revokes trusted Droid after accepted no-OSC exit input until shell confirmation', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('cmd.exe') + const pane = createPane(1) + const ptyId = 'pty-droid-exit-no-osc' + const tabId = 'tab-droid-exit-no-osc' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + connectPanePty(pane as never, createManager(1) as never, createDeps({ tabId }) as never) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.paneForegroundAgentByPaneKey[paneKey] = { + agent: 'droid', + routingTrusted: true, + shellForeground: false + } + + sendTerminalInputThroughPane(pane, '\x03') + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingRevoked: true, + routingConfirmationPending: true, + shellForeground: false + }) + // The provider read is asynchronous; keep the last known safe capability + // so a second Shift+Enter cannot become Pi's submit chord in the gap. + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: true + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + }) + + it('trusts Pi in a no-OSC shell and retires routing after accepted exit input', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + let foreground = 'pi' + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('pi') + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) + const pane = createPane(1) + const ptyId = 'pty-pi-no-osc-lifecycle' + const tabId = 'tab-pi-no-osc-lifecycle' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, startup: { command: 'pi', launchAgent: 'pi' } }) as never + ) as unknown as { requestWindowsShiftEnterReconfirmation: () => void } + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Captured transport options expose onPtySpawn as an optional PTY-id callback. + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((id: string) => void) + | undefined + onPtySpawn?.(ptyId) + await vi.advanceTimersByTimeAsync( + VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + ) + await flushAsyncTicks() + + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + + binding.requestWindowsShiftEnterReconfirmation() + await vi.advanceTimersByTimeAsync(200) + binding.requestWindowsShiftEnterReconfirmation() + await vi.advanceTimersByTimeAsync(700) + await flushAsyncTicks() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + + foreground = 'cmd.exe' + sendTerminalInputThroughPane(pane, '\x03') + await flushAsyncTicks() + // A provider read is pending; do not turn the next Pi Shift+Enter into submit. + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + await vi.advanceTimersByTimeAsync( + VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + ) + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: true + }) + }) + + it('never promotes typed Droid text when foreground enrichment is unavailable', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('inspection unavailable')) + .mockResolvedValueOnce(null) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const ptyId = 'pty-typed-droid-unavailable-start' + const tabId = 'tab-typed-droid-unavailable-start' + const paneKey = makePaneKey(tabId, LEAF_1) + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + + sendTerminalInputThroughPane(pane, 'droid\r') + dataCallbackRef.current?.('\x1b]133;C\x07') + await vi.advanceTimersByTimeAsync(350) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + await vi.advanceTimersByTimeAsync(1200) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + await vi.advanceTimersByTimeAsync(5999) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + await vi.advanceTimersByTimeAsync(1) + + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts deleted file mode 100644 index 144c1c23dc2..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts +++ /dev/null @@ -1,626 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - VISIBLE_PTY_SETTLE_MS, - WRAPPER_RESOLVE_RETRY_MS, - SECOND_WRAPPER_RETRY_MS -} from './pty-connection-test-constants' -import { sendTerminalInputThroughPane } from './pty-connection-test-dom' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { - resolveMockPaneWindowsShiftEnterEncoding, - type StoreState -} from './pty-connection-test-store-state' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('drops agent status without retaining when OSC 133 reports the command finished', async () => { - const { connectPanePty } = await import('./pty-connection') - - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport() - let currentPtyId: string | null = null - vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - currentPtyId = 'pty-local-1' - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-local-1' - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - state: 'done', - prompt: 'hi', - updatedAt: 1000, - stateStartedAt: 1000, - agentType: 'codex', - stateHistory: [] - } - } - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ isVisibleRef: { current: false } }) - - connectPanePty(pane as never, manager as never, deps as never) - - capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - await flushAsyncTicks() - - expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) - expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() - }) - - it('clears pre-hook launch config when an Orca-started command exits', async () => { - vi.useFakeTimers({ toFake: ['setTimeout'] }) - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') - - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport() - let currentPtyId: string | null = null - vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - currentPtyId = 'pty-local-1' - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-local-1' - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - ptyIdsByTabId: { 'tab-1': [] } - } as StoreState - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ - startup: { - command: "codex '--dangerously-bypass-approvals-and-sandbox'", - launchConfig: { - agentArgs: '--dangerously-bypass-approvals-and-sandbox', - agentEnv: {} - }, - launchAgent: 'codex' - }, - restoredPtyIdByLeafId: {} - }) as never - ) - - expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith( - paneKey, - { - agentArgs: '--dangerously-bypass-approvals-and-sandbox', - agentEnv: {} - }, - expect.objectContaining({ agentType: 'codex' }) - ) - capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - await flushAsyncTicks() - await vi.advanceTimersByTimeAsync(350) - await flushAsyncTicks() - - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) - expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - }) - - it('routes a manually typed Droid only after foreground enrichment confirms it', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const pane = createPane(1) - const ptyId = 'pty-manually-typed-droid' - const tabId = 'tab-manually-typed-droid' - const foregroundResults = ['powershell.exe', 'droid'] - vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async (id: string) => - id === ptyId ? (foregroundResults.shift() ?? 'droid') : null - ) - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey(tabId, LEAF_1) - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ tabId, isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - - sendTerminalInputThroughPane(pane, 'droid\r') - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - - dataCallbackRef.current?.('\x1b]133;C\x07') - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - await vi.advanceTimersByTimeAsync(350) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - await vi.advanceTimersByTimeAsync(1200) - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: 'droid', - agentEvidence: 'process-read', - routingTrusted: true, - shellForeground: false - }) - }) - - it('confirms a manually typed Droid without OSC command boundaries', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') - const pane = createPane(1) - const ptyId = 'pty-manual-droid-no-osc' - const tabId = 'tab-manual-droid-no-osc' - const paneKey = makePaneKey(tabId, LEAF_1) - transportFactoryQueue.push(createMockTransport(ptyId)) - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ tabId, isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - - sendTerminalInputThroughPane(pane, 'droid\r') - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - - await vi.advanceTimersByTimeAsync(350) - - expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: 'droid', - agentEvidence: 'process-read', - routingTrusted: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') - }) - - it('confirms an Orca-launched Droid fresh spawn in a no-OSC shell (Git Bash)', async () => { - // Why: no-OSC shells (Git Bash/cmd) emit no command boundary, so without a fresh-spawn sample the pane never earns routing trust and Shift+Enter regresses to Esc+CR (#7620). - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('droid') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') - const pane = createPane(1) - const ptyId = 'pty-launched-droid-no-osc' - const tabId = 'tab-launched-droid-no-osc' - const paneKey = makePaneKey(tabId, LEAF_1) - transportFactoryQueue.push(createMockTransport(ptyId)) - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - tabId, - startup: { command: 'droid', launchAgent: 'droid' } - }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - // The transport reports the fresh spawn; no OSC 133, no typed inference. - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined - expect(onPtySpawn).toBeTypeOf('function') - onPtySpawn?.(ptyId) - await vi.advanceTimersByTimeAsync( - VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS - ) - await flushAsyncTicks() - - expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: 'droid', - agentEvidence: 'process-read', - routingTrusted: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') - }) - - it('trusts a launched Droid whose no-OSC boot only becomes foreground after retries', async () => { - // Why: the confirmation ladder must span Droid's boot — the shell is still foreground on the first read(s) before Droid takes over. - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const foregroundResults = ['bash.exe', 'bash.exe', 'droid'] - vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation( - async () => foregroundResults.shift() ?? 'droid' - ) - const pane = createPane(1) - const ptyId = 'pty-launched-droid-slow-boot' - const tabId = 'tab-launched-droid-slow-boot' - const paneKey = makePaneKey(tabId, LEAF_1) - transportFactoryQueue.push(createMockTransport(ptyId)) - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ tabId, startup: { command: 'droid', launchAgent: 'droid' } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined - onPtySpawn?.(ptyId) - await vi.advanceTimersByTimeAsync( - VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS - ) - await flushAsyncTicks() - - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: 'droid', - agentEvidence: 'process-read', - routingTrusted: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') - }) - - it('stays recoverable when a launched Droid outlasts the fresh-spawn confirmation window', async () => { - // Why: a missed ladder (slow boot) must stay recoverable — latching a shell-confirm would clear launch identity and poison Shift+Enter for the session. - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - let foreground = 'bash.exe' - // Why both: the pane's process monitor reads the same foreground the confirm read sees. - vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) - vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) - const pane = createPane(1) - const ptyId = 'pty-launched-droid-slow' - const tabId = 'tab-launched-droid-slow' - const paneKey = makePaneKey(tabId, LEAF_1) - transportFactoryQueue.push(createMockTransport(ptyId)) - - const binding = connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ tabId, startup: { command: 'droid', launchAgent: 'droid' } }) as never - ) as unknown as { sampleForegroundAgentOnFocus: () => void } - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined - onPtySpawn?.(ptyId) - // Whole ladder elapses while the shell is still foreground (Droid not up yet). - await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - await flushAsyncTicks() - - // Benign miss: shell never latched as foreground; encoding still safe fallback. - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: null, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - - // Droid finally boots and a focus event re-samples: trust is recoverable. - foreground = 'droid' - binding.sampleForegroundAgentOnFocus() - await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - await flushAsyncTicks() - - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: 'droid', - agentEvidence: 'process-read', - routingTrusted: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') - }) - - it('confirms a delayed Claude from fresh startup output after the first read window expires', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - let foreground = 'sleep' - vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) - vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) - const tabId = 'tab-delayed-claude' - const ptyId = 'pty-delayed-claude' - const paneKey = makePaneKey(tabId, LEAF_1) - transportFactoryQueue.push(createMockTransport(ptyId)) - const binding = connectPanePty( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The pane fixture implements the terminal APIs exercised by this connection test. - createPane(1) as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The manager fixture supplies this single pane and its connection callbacks. - createManager(1) as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection dependency fixture supplies all APIs used by the test with the explicit startup override. - createDeps({ tabId, startup: { command: 'sleep 30 && claude' } }) as never - ) - await vi.advanceTimersByTimeAsync(30_000) - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]?.agent).not.toBe('claude') - - foreground = 'claude' - const onTitleChange = createdTransportOptions[0]?.onTitleChange - if (typeof onTitleChange !== 'function') { - throw new Error('Missing live title consumer') - } - // Captured with capture-agent-pty-transcript.mjs from a real delayed Claude launch. - onTitleChange('✳ Claude Code', '✳ Claude Code') - await vi.advanceTimersByTimeAsync(350) - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toMatchObject({ - agent: 'claude', - agentEvidence: 'process-read', - routingTrusted: true - }) - binding.dispose() - }) - - it('revokes trusted Droid after accepted no-OSC exit input until shell confirmation', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('cmd.exe') - const pane = createPane(1) - const ptyId = 'pty-droid-exit-no-osc' - const tabId = 'tab-droid-exit-no-osc' - const paneKey = makePaneKey(tabId, LEAF_1) - transportFactoryQueue.push(createMockTransport(ptyId)) - - connectPanePty(pane as never, createManager(1) as never, createDeps({ tabId }) as never) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.paneForegroundAgentByPaneKey[paneKey] = { - agent: 'droid', - routingTrusted: true, - shellForeground: false - } - - sendTerminalInputThroughPane(pane, '\x03') - await flushAsyncTicks() - - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: 'droid', - routingRevoked: true, - routingConfirmationPending: true, - shellForeground: false - }) - // The provider read is asynchronous; keep the last known safe capability - // so a second Shift+Enter cannot become Pi's submit chord in the gap. - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') - - await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: null, - shellForeground: true - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - }) - - it('trusts Pi in a no-OSC shell and retires routing after accepted exit input', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - let foreground = 'pi' - vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('pi') - vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) - const pane = createPane(1) - const ptyId = 'pty-pi-no-osc-lifecycle' - const tabId = 'tab-pi-no-osc-lifecycle' - const paneKey = makePaneKey(tabId, LEAF_1) - transportFactoryQueue.push(createMockTransport(ptyId)) - - const binding = connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ tabId, startup: { command: 'pi', launchAgent: 'pi' } }) as never - ) as unknown as { requestWindowsShiftEnterReconfirmation: () => void } - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined - onPtySpawn?.(ptyId) - await vi.advanceTimersByTimeAsync( - VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS - ) - await flushAsyncTicks() - - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') - - binding.requestWindowsShiftEnterReconfirmation() - await vi.advanceTimersByTimeAsync(200) - binding.requestWindowsShiftEnterReconfirmation() - await vi.advanceTimersByTimeAsync(700) - await flushAsyncTicks() - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') - - foreground = 'cmd.exe' - sendTerminalInputThroughPane(pane, '\x03') - await flushAsyncTicks() - // A provider read is pending; do not turn the next Pi Shift+Enter into submit. - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') - await vi.advanceTimersByTimeAsync( - VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS - ) - await flushAsyncTicks() - - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: null, - shellForeground: true - }) - }) - - it('never promotes typed Droid text when foreground enrichment is unavailable', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess) - .mockResolvedValueOnce(null) - .mockRejectedValueOnce(new Error('inspection unavailable')) - .mockResolvedValueOnce(null) - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const pane = createPane(1) - const ptyId = 'pty-typed-droid-unavailable-start' - const tabId = 'tab-typed-droid-unavailable-start' - const paneKey = makePaneKey(tabId, LEAF_1) - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - }) - transportFactoryQueue.push(transport) - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ tabId, isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - - sendTerminalInputThroughPane(pane, 'droid\r') - dataCallbackRef.current?.('\x1b]133;C\x07') - await vi.advanceTimersByTimeAsync(350) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - - await vi.advanceTimersByTimeAsync(1200) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - - await vi.advanceTimersByTimeAsync(5999) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - await vi.advanceTimersByTimeAsync(1) - - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.spec.ts new file mode 100644 index 00000000000..a0d3710c596 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.spec.ts @@ -0,0 +1,819 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + VISIBLE_PTY_SETTLE_MS, + WRAPPER_RESOLVE_RETRY_MS, + SECOND_WRAPPER_RETRY_MS +} from './pty-connection-test-constants' +import { temporarilySetNavigatorUserAgent } from './pty-connection-test-dom' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { + resolveMockPaneWindowsShiftEnterEncoding, + type StoreState +} from './pty-connection-test-store-state' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionForegroundAgentSamplingTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + describe('visible foreground agent sampling (perf)', () => { + // Why: bindings share the tab-1/LEAF_1 pane key; give each sampling case its own tabId so no other test's publish pollutes it. + async function connectRestoredPaneForForegroundSampling( + args: { + ptyId?: string + tabId?: string + isVisibleRef?: { current: boolean } + launchConfig?: { + agentCommand?: string + agentArgs: string + agentEnv: Record + } + launchAgent?: TuiAgent + } = {} + ): Promise<{ + binding: { + noteVisibilityResume: () => void + sampleForegroundAgentOnFocus: () => void + requestWindowsShiftEnterReconfirmation: () => void + } + deps: ReturnType + transport: MockTransport + cacheKey: string + }> { + const { connectPanePty } = await import('./pty-connection') + const ptyId = args.ptyId ?? 'tab-pty' + const tabId = args.tabId ?? `tab-${ptyId}` + const hasReattachMetadata = + args.launchConfig !== undefined || args.launchAgent !== undefined + const transport = createMockTransport(hasReattachMetadata ? null : ptyId) + let connectedPtyId: string | null = hasReattachMetadata ? null : ptyId + transport.getPtyId.mockImplementation(() => connectedPtyId) + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + connectedPtyId = sessionId ?? null + return sessionId + ? { + id: sessionId, + ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), + ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}) + } + : null + }) + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId }, + ...(args.isVisibleRef ? { isVisibleRef: args.isVisibleRef } : {}) + }) + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) as unknown as { + noteVisibilityResume: () => void + sampleForegroundAgentOnFocus: () => void + requestWindowsShiftEnterReconfirmation: () => void + } + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + return { binding, deps, transport, cacheKey: makePaneKey(tabId, LEAF_1) } + } + + async function advanceVisibleForegroundRead(): Promise { + await vi.advanceTimersByTimeAsync(VISIBLE_PTY_SETTLE_MS) + await flushAsyncTicks() + } + + function foregroundReadCallsFor(ptyId: string): unknown[][] { + return vi + .mocked(window.api.pty.getForegroundProcess) + .mock.calls.filter(([calledPtyId]) => calledPtyId === ptyId) + } + + it('does not inspect foreground process for a fresh visible spawn', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-fresh-visible-no-sample' + transportFactoryQueue.push(createMockTransport(ptyId)) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + } as StoreState + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + const spawnHandler = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + spawnHandler?.(ptyId) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('samples exactly one visible restored PTY with no stronger identity signal', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-restored-visible-sample' + + const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId }) + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { + agent: 'codex', + agentEvidence: 'process-read', + shellForeground: false + }) + }) + + it('does not sample hidden restored PTYs', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-hidden-restored-no-sample' + + await connectRestoredPaneForForegroundSampling({ + ptyId, + isVisibleRef: { current: false } + }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('does not confirm foreground routing for a Windows WSL pane', async () => { + vi.useFakeTimers() + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const ptyId = 'pty-wsl-no-confirm' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: tabId, ptyId, shellOverride: 'wsl.exe' }] + } + + try { + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId + }) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'droid', + routingTrusted: true, + shellForeground: false + } + + binding.sampleForegroundAgentOnFocus() + await vi.advanceTimersByTimeAsync(10_000) + + // Scope to this pane's pty id: a delayed confirm for another test's pane can fire during this advance. + expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalledWith(ptyId) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + routingRevoked: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe( + 'alt-enter' + ) + } finally { + restoreUserAgent() + } + }) + + it('does not confirm foreground routing for a global Windows WSL shell', async () => { + vi.useFakeTimers() + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const ptyId = 'pty-global-wsl-no-confirm' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: tabId, ptyId }] + } + mockStoreState.settings = { + ...mockStoreState.settings, + terminalWindowsShell: 'wsl.exe' + } + + try { + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId + }) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'droid', + routingTrusted: true, + shellForeground: false + } + + binding.sampleForegroundAgentOnFocus() + await vi.advanceTimersByTimeAsync(10_000) + + expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalledWith(ptyId) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + routingRevoked: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe( + 'alt-enter' + ) + } finally { + restoreUserAgent() + } + }) + + it('keeps trusted Droid routing through a rapid Shift+Enter burst', async () => { + vi.useFakeTimers() + const ptyId = 'pty-droid-shift-enter-burst' + const tabId = `tab-${ptyId}` + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId + }) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'droid', + routingTrusted: true, + shellForeground: false + } + mockStoreState.agentStatusByPaneKey[cacheKey] = { + state: 'working', + agentType: 'droid' + } + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + + binding.requestWindowsShiftEnterReconfirmation() + await vi.advanceTimersByTimeAsync(200) + binding.requestWindowsShiftEnterReconfirmation() + await vi.advanceTimersByTimeAsync(349) + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + + await vi.advanceTimersByTimeAsync(1) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + routingRevoked: true, + routingConfirmationPending: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u') + + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + }) + + binding.requestWindowsShiftEnterReconfirmation() + await vi.advanceTimersByTimeAsync(700) + await flushAsyncTicks() + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + }) + }) + + it('revokes stale trusted Pi routing on focus before confirming the shell', async () => { + vi.useFakeTimers() + const ptyId = 'pty-pi-focus-after-exit' + const tabId = `tab-${ptyId}` + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId + }) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'pi', + routingTrusted: true, + shellForeground: false + } + mockStoreState.agentStatusByPaneKey[cacheKey] = { + state: 'working', + agentType: 'pi' + } + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('cmd.exe') + + binding.sampleForegroundAgentOnFocus() + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'pi', + routingRevoked: true, + routingConfirmationPending: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u') + + await vi.advanceTimersByTimeAsync( + VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + ) + await flushAsyncTicks() + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: null, + shellForeground: true + }) + }) + + it('samples once when an identityless hidden pane resumes visible', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const isVisibleRef = { current: false } + const ptyId = 'pty-hidden-then-visible-sample' + const { binding } = await connectRestoredPaneForForegroundSampling({ ptyId, isVisibleRef }) + await advanceVisibleForegroundRead() + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + + isVisibleRef.current = true + binding.noteVisibilityResume() + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) + }) + + it('confirms daemon launch identity before restoring warm-reattach routing', async () => { + vi.useFakeTimers() + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + const ptyId = 'pty-launch-identity-no-sample' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } + + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + launchAgent: 'droid' + }) + expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled() + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + agentEvidence: 'launch-record', + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + + binding.sampleForegroundAgentOnFocus() + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + agentEvidence: 'launch-record', + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + + await advanceVisibleForegroundRead() + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u') + }) + + it("keeps this session's process read when a parked pane re-attaches to the same agent", async () => { + vi.useFakeTimers() + const ptyId = 'pty-parked-reattach-keeps-read' + const tabId = `tab-${ptyId}` + const cacheKey = makePaneKey(tabId, LEAF_1) + mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'codex', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + } + + await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + isVisibleRef: { current: false }, + launchAgent: 'codex' + }) + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'codex', + agentEvidence: 'process-read', + shellForeground: false + }) + }) + + it('seeds a launch record when the prior read named a different agent', async () => { + vi.useFakeTimers() + const ptyId = 'pty-parked-reattach-other-agent' + const tabId = `tab-${ptyId}` + const cacheKey = makePaneKey(tabId, LEAF_1) + mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'claude', + agentEvidence: 'process-read', + shellForeground: false + } + + await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + isVisibleRef: { current: false }, + launchAgent: 'codex' + }) + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'codex', + agentEvidence: 'launch-record', + shellForeground: false + }) + }) + + it('retires stale daemon launch identity when warm reattach finds the shell', async () => { + vi.useFakeTimers() + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + const ptyId = 'pty-stale-daemon-launch-identity' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } + + const { cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + launchAgent: 'droid' + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + + await vi.advanceTimersByTimeAsync( + VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + ) + await flushAsyncTicks() + + // Assert the floor (initial read + two retries), not an exact count: an incidental droid reconfirm can add one more. + expect( + vi.mocked(window.api.pty.confirmForegroundProcess).mock.calls.length + ).toBeGreaterThanOrEqual(3) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: null, + shellForeground: true + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + }) + + it('fails closed when a warm reattach has no persisted launch identity', async () => { + vi.useFakeTimers() + const ptyId = 'pty-reattach-missing-launch-identity' + const tabId = `tab-${ptyId}` + + const { cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + isVisibleRef: { current: false } + }) + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toBeUndefined() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + }) + + it('does not let persisted args spoof pane identity without an allowlisted command', async () => { + vi.useFakeTimers() + const ptyId = 'pty-unknown-persisted-launch' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } + mockStoreState.registerAgentLaunchConfig.mockImplementation( + (key: string, launchConfig: unknown, identity: { agentType?: string }): void => { + mockStoreState.agentLaunchConfigByPaneKey[key] = { launchConfig, identity } + } + ) + + const { cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + isVisibleRef: { current: false }, + launchConfig: { + agentCommand: 'custom-wrapper --agent droid', + agentArgs: 'droid', + agentEnv: {} + } + }) + await vi.advanceTimersByTimeAsync(300) + await flushAsyncTicks(20) + + expect( + mockStoreState.agentLaunchConfigByPaneKey[cacheKey]?.identity?.agentType + ).toBeUndefined() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + }) + + it('does not re-scan stale launch metadata after the local ladder confirms shell', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('powershell.exe') + const isVisibleRef = { current: false } + const ptyId = 'pty-stale-launch-shell-settled' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: tabId, ptyId, launchAgent: 'droid' }] + } + mockStoreState.registerAgentLaunchConfig.mockImplementation( + (key: string, launchConfig: unknown, identity: { agentType?: string }): void => { + mockStoreState.agentLaunchConfigByPaneKey[key] = { launchConfig, identity } + } + ) + mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { + delete mockStoreState.agentLaunchConfigByPaneKey[key] + }) + + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + isVisibleRef, + launchConfig: { agentCommand: 'droid', agentArgs: '', agentEnv: {} } + }) + await vi.advanceTimersByTimeAsync(300) + await flushAsyncTicks(20) + // Launch metadata starts confirmation but is never byte-routing authority. + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + isVisibleRef.current = true + binding.noteVisibilityResume() + await advanceVisibleForegroundRead() + await vi.advanceTimersByTimeAsync(WRAPPER_RESOLVE_RETRY_MS) + await vi.advanceTimersByTimeAsync(SECOND_WRAPPER_RETRY_MS) + + expect(foregroundReadCallsFor(ptyId).length).toBeGreaterThanOrEqual(3) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: null, + shellForeground: true + }) + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(cacheKey) + + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: null, + shellForeground: false + } + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + + vi.clearAllTimers() + const settledReadCount = foregroundReadCallsFor(ptyId).length + binding.sampleForegroundAgentOnFocus() + await vi.advanceTimersByTimeAsync( + VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + ) + expect(foregroundReadCallsFor(ptyId)).toHaveLength(settledReadCount) + }) + + it('does not sample when a live hook row already supplies pane identity', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-hook-identity-no-sample' + const tabId = `tab-${ptyId}` + mockStoreState.agentStatusByPaneKey[makePaneKey(tabId, LEAF_1)] = { + state: 'working', + agentType: 'codex' + } + + await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('does not sample when process identity is already known', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-process-identity-no-sample' + const tabId = `tab-${ptyId}` + mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { + agent: 'codex', + routingTrusted: true, + shellForeground: false + } + + await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('does not re-sample once 133;D proved the pane is at a shell prompt', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-shell-foreground-no-sample' + const tabId = `tab-${ptyId}` + mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { + agent: null, + shellForeground: true + } + + await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('does not re-sample a shell-confirmed pane from stale launch metadata', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-shell-foreground-launch-agent-sample' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: tabId, ptyId, launchAgent: 'codex' }] + } + mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { + agent: null, + shellForeground: true + } + + const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalledWith( + cacheKey, + expect.anything() + ) + }) + + it('fails closed when a leaked 133;D cancels identityless recovery', async () => { + // Why: a cached visible read has no routing authority; on a racing command boundary, trust the shell marker over stale identity. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-reattach-idle-codex-leaked-d' + const tabId = `tab-${ptyId}` + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ + sessionId, + callbacks + }: { + sessionId?: string + callbacks?: ConnectCallbacks + }) => { + dataCallbackRef.current = callbacks?.onData ?? null + return sessionId ? { id: sessionId } : null + } + ) + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId } + }) + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + + const cacheKey = makePaneKey(tabId, LEAF_1) + dataCallbackRef.current?.('\x1b]133;D;0\x07') + + await advanceVisibleForegroundRead() + + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { + agent: null, + shellForeground: true + }) + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('re-confirms leaked 133;D after detach moved pane-scoped Droid identity', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('droid') + const ptyId = 'pty-detached-droid-leaked-d' + const tabId = 'tab-detached-droid' + const cacheKey = makePaneKey(tabId, LEAF_1) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ + sessionId, + callbacks + }: { + sessionId?: string + callbacks?: ConnectCallbacks + }) => { + dataCallbackRef.current = callbacks?.onData ?? null + return sessionId ? { id: sessionId } : null + } + ) + transportFactoryQueue.push(transport) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'droid', + shellForeground: false + } + mockStoreState.agentLaunchConfigByPaneKey[cacheKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + const deps = createDeps({ + tabId, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) + expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalledWith(cacheKey, { + agent: null, + shellForeground: true + }) + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { + agent: 'droid', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + }) + }) + + it('never probes the foreground for a visible remote/SSH restored pane', async () => { + // Why: foreground reads are local-only (expensive RPCs); keep remote/SSH panes off the recovery probe. + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'remote:web-env-1@@pty-remote-idle-agent' + + await connectRestoredPaneForForegroundSampling({ ptyId, tabId: 'tab-remote-idle-agent' }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts deleted file mode 100644 index 54fcc4e898a..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts +++ /dev/null @@ -1,890 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - VISIBLE_PTY_SETTLE_MS, - WRAPPER_RESOLVE_RETRY_MS, - SECOND_WRAPPER_RETRY_MS -} from './pty-connection-test-constants' -import { temporarilySetNavigatorUserAgent } from './pty-connection-test-dom' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { - resolveMockPaneWindowsShiftEnterEncoding, - type StoreState -} from './pty-connection-test-store-state' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - describe('visible foreground agent sampling (perf)', () => { - // Why: bindings share the tab-1/LEAF_1 pane key; give each sampling case its own tabId so no other test's publish pollutes it. - async function connectRestoredPaneForForegroundSampling( - args: { - ptyId?: string - tabId?: string - isVisibleRef?: { current: boolean } - launchConfig?: { - agentCommand?: string - agentArgs: string - agentEnv: Record - } - launchAgent?: TuiAgent - } = {} - ): Promise<{ - binding: { - noteVisibilityResume: () => void - sampleForegroundAgentOnFocus: () => void - requestWindowsShiftEnterReconfirmation: () => void - } - deps: ReturnType - transport: MockTransport - cacheKey: string - }> { - const { connectPanePty } = await import('./pty-connection') - const ptyId = args.ptyId ?? 'tab-pty' - const tabId = args.tabId ?? `tab-${ptyId}` - const hasReattachMetadata = args.launchConfig !== undefined || args.launchAgent !== undefined - const transport = createMockTransport(hasReattachMetadata ? null : ptyId) - let connectedPtyId: string | null = hasReattachMetadata ? null : ptyId - transport.getPtyId.mockImplementation(() => connectedPtyId) - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - connectedPtyId = sessionId ?? null - return sessionId - ? { - id: sessionId, - ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), - ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}) - } - : null - }) - transportFactoryQueue.push(transport) - const deps = createDeps({ - tabId, - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: ptyId }, - ...(args.isVisibleRef ? { isVisibleRef: args.isVisibleRef } : {}) - }) - const binding = connectPanePty( - createPane(1) as never, - createManager(1) as never, - deps as never - ) as unknown as { - noteVisibilityResume: () => void - sampleForegroundAgentOnFocus: () => void - requestWindowsShiftEnterReconfirmation: () => void - } - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks(20) - return { binding, deps, transport, cacheKey: makePaneKey(tabId, LEAF_1) } - } - - async function advanceVisibleForegroundRead(): Promise { - await vi.advanceTimersByTimeAsync(VISIBLE_PTY_SETTLE_MS) - await flushAsyncTicks() - } - - function foregroundReadCallsFor(ptyId: string): unknown[][] { - return vi - .mocked(window.api.pty.getForegroundProcess) - .mock.calls.filter(([calledPtyId]) => calledPtyId === ptyId) - } - - it('does not inspect foreground process for a fresh visible spawn', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const ptyId = 'pty-fresh-visible-no-sample' - transportFactoryQueue.push(createMockTransport(ptyId)) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - ptyIdsByTabId: { 'tab-1': [] }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_1 }, - activeLeafId: LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - } as StoreState - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks(20) - const spawnHandler = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - spawnHandler?.(ptyId) - await advanceVisibleForegroundRead() - - expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) - }) - - it('samples exactly one visible restored PTY with no stronger identity signal', async () => { - vi.useFakeTimers() - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const ptyId = 'pty-restored-visible-sample' - - const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId }) - expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) - - await advanceVisibleForegroundRead() - - expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) - expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { - agent: 'codex', - agentEvidence: 'process-read', - shellForeground: false - }) - }) - - it('does not sample hidden restored PTYs', async () => { - vi.useFakeTimers() - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const ptyId = 'pty-hidden-restored-no-sample' - - await connectRestoredPaneForForegroundSampling({ - ptyId, - isVisibleRef: { current: false } - }) - await advanceVisibleForegroundRead() - - expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) - }) - - it('does not confirm foreground routing for a Windows WSL pane', async () => { - vi.useFakeTimers() - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const ptyId = 'pty-wsl-no-confirm' - const tabId = `tab-${ptyId}` - mockStoreState.tabsByWorktree = { - 'wt-1': [{ id: tabId, ptyId, shellOverride: 'wsl.exe' }] - } - - try { - const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ - ptyId, - tabId - }) - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: 'droid', - routingTrusted: true, - shellForeground: false - } - - binding.sampleForegroundAgentOnFocus() - await vi.advanceTimersByTimeAsync(10_000) - - // Scope to this pane's pty id: a delayed confirm for another test's pane can fire during this advance. - expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalledWith(ptyId) - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'droid', - routingRevoked: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - } finally { - restoreUserAgent() - } - }) - - it('does not confirm foreground routing for a global Windows WSL shell', async () => { - vi.useFakeTimers() - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const ptyId = 'pty-global-wsl-no-confirm' - const tabId = `tab-${ptyId}` - mockStoreState.tabsByWorktree = { - 'wt-1': [{ id: tabId, ptyId }] - } - mockStoreState.settings = { - ...mockStoreState.settings, - terminalWindowsShell: 'wsl.exe' - } - - try { - const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ - ptyId, - tabId - }) - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: 'droid', - routingTrusted: true, - shellForeground: false - } - - binding.sampleForegroundAgentOnFocus() - await vi.advanceTimersByTimeAsync(10_000) - - expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalledWith(ptyId) - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'droid', - routingRevoked: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - } finally { - restoreUserAgent() - } - }) - - it('keeps trusted Droid routing through a rapid Shift+Enter burst', async () => { - vi.useFakeTimers() - const ptyId = 'pty-droid-shift-enter-burst' - const tabId = `tab-${ptyId}` - const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ - ptyId, - tabId - }) - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: 'droid', - routingTrusted: true, - shellForeground: false - } - mockStoreState.agentStatusByPaneKey[cacheKey] = { - state: 'working', - agentType: 'droid' - } - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') - - binding.requestWindowsShiftEnterReconfirmation() - await vi.advanceTimersByTimeAsync(200) - binding.requestWindowsShiftEnterReconfirmation() - await vi.advanceTimersByTimeAsync(349) - - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'droid', - routingTrusted: true, - shellForeground: false - }) - - await vi.advanceTimersByTimeAsync(1) - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'droid', - routingRevoked: true, - routingConfirmationPending: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u') - - await vi.advanceTimersByTimeAsync(350) - await flushAsyncTicks() - expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'droid', - agentEvidence: 'process-read', - routingTrusted: true, - shellForeground: false - }) - - binding.requestWindowsShiftEnterReconfirmation() - await vi.advanceTimersByTimeAsync(700) - await flushAsyncTicks() - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'droid', - agentEvidence: 'process-read', - routingTrusted: true, - shellForeground: false - }) - }) - - it('revokes stale trusted Pi routing on focus before confirming the shell', async () => { - vi.useFakeTimers() - const ptyId = 'pty-pi-focus-after-exit' - const tabId = `tab-${ptyId}` - const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: 'pi', - routingTrusted: true, - shellForeground: false - } - mockStoreState.agentStatusByPaneKey[cacheKey] = { - state: 'working', - agentType: 'pi' - } - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('cmd.exe') - - binding.sampleForegroundAgentOnFocus() - - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'pi', - routingRevoked: true, - routingConfirmationPending: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u') - - await vi.advanceTimersByTimeAsync( - VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS - ) - await flushAsyncTicks() - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: null, - shellForeground: true - }) - }) - - it('samples once when an identityless hidden pane resumes visible', async () => { - vi.useFakeTimers() - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const isVisibleRef = { current: false } - const ptyId = 'pty-hidden-then-visible-sample' - const { binding } = await connectRestoredPaneForForegroundSampling({ ptyId, isVisibleRef }) - await advanceVisibleForegroundRead() - expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) - - isVisibleRef.current = true - binding.noteVisibilityResume() - await advanceVisibleForegroundRead() - - expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) - }) - - it('confirms daemon launch identity before restoring warm-reattach routing', async () => { - vi.useFakeTimers() - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') - const ptyId = 'pty-launch-identity-no-sample' - const tabId = `tab-${ptyId}` - mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } - - const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ - ptyId, - tabId, - launchAgent: 'droid' - }) - expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled() - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'droid', - agentEvidence: 'launch-record', - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - - binding.sampleForegroundAgentOnFocus() - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'droid', - agentEvidence: 'launch-record', - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - - await advanceVisibleForegroundRead() - - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'droid', - agentEvidence: 'process-read', - routingTrusted: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u') - }) - - it("keeps this session's process read when a parked pane re-attaches to the same agent", async () => { - vi.useFakeTimers() - const ptyId = 'pty-parked-reattach-keeps-read' - const tabId = `tab-${ptyId}` - const cacheKey = makePaneKey(tabId, LEAF_1) - mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: 'codex', - agentEvidence: 'process-read', - routingTrusted: true, - shellForeground: false - } - - await connectRestoredPaneForForegroundSampling({ - ptyId, - tabId, - isVisibleRef: { current: false }, - launchAgent: 'codex' - }) - - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'codex', - agentEvidence: 'process-read', - shellForeground: false - }) - }) - - it('seeds a launch record when the prior read named a different agent', async () => { - vi.useFakeTimers() - const ptyId = 'pty-parked-reattach-other-agent' - const tabId = `tab-${ptyId}` - const cacheKey = makePaneKey(tabId, LEAF_1) - mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: 'claude', - agentEvidence: 'process-read', - shellForeground: false - } - - await connectRestoredPaneForForegroundSampling({ - ptyId, - tabId, - isVisibleRef: { current: false }, - launchAgent: 'codex' - }) - - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'codex', - agentEvidence: 'launch-record', - shellForeground: false - }) - }) - - it('retires stale daemon launch identity when warm reattach finds the shell', async () => { - vi.useFakeTimers() - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') - const ptyId = 'pty-stale-daemon-launch-identity' - const tabId = `tab-${ptyId}` - mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } - - const { cacheKey } = await connectRestoredPaneForForegroundSampling({ - ptyId, - tabId, - launchAgent: 'droid' - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - - await vi.advanceTimersByTimeAsync( - VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS - ) - await flushAsyncTicks() - - // Assert the floor (initial read + two retries), not an exact count: an incidental droid reconfirm can add one more. - expect( - vi.mocked(window.api.pty.confirmForegroundProcess).mock.calls.length - ).toBeGreaterThanOrEqual(3) - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: null, - shellForeground: true - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - }) - - it('fails closed when a warm reattach has no persisted launch identity', async () => { - vi.useFakeTimers() - const ptyId = 'pty-reattach-missing-launch-identity' - const tabId = `tab-${ptyId}` - - const { cacheKey } = await connectRestoredPaneForForegroundSampling({ - ptyId, - tabId, - isVisibleRef: { current: false } - }) - - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toBeUndefined() - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - }) - - it('does not let persisted args spoof pane identity without an allowlisted command', async () => { - vi.useFakeTimers() - const ptyId = 'pty-unknown-persisted-launch' - const tabId = `tab-${ptyId}` - mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } - mockStoreState.registerAgentLaunchConfig.mockImplementation( - (key: string, launchConfig: unknown, identity: { agentType?: string }): void => { - mockStoreState.agentLaunchConfigByPaneKey[key] = { launchConfig, identity } - } - ) - - const { cacheKey } = await connectRestoredPaneForForegroundSampling({ - ptyId, - tabId, - isVisibleRef: { current: false }, - launchConfig: { - agentCommand: 'custom-wrapper --agent droid', - agentArgs: 'droid', - agentEnv: {} - } - }) - await vi.advanceTimersByTimeAsync(300) - await flushAsyncTicks(20) - - expect( - mockStoreState.agentLaunchConfigByPaneKey[cacheKey]?.identity?.agentType - ).toBeUndefined() - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - }) - - it('does not re-scan stale launch metadata after the local ladder confirms shell', async () => { - vi.useFakeTimers() - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('powershell.exe') - const isVisibleRef = { current: false } - const ptyId = 'pty-stale-launch-shell-settled' - const tabId = `tab-${ptyId}` - mockStoreState.tabsByWorktree = { - 'wt-1': [{ id: tabId, ptyId, launchAgent: 'droid' }] - } - mockStoreState.registerAgentLaunchConfig.mockImplementation( - (key: string, launchConfig: unknown, identity: { agentType?: string }): void => { - mockStoreState.agentLaunchConfigByPaneKey[key] = { launchConfig, identity } - } - ) - mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { - delete mockStoreState.agentLaunchConfigByPaneKey[key] - }) - - const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ - ptyId, - tabId, - isVisibleRef, - launchConfig: { agentCommand: 'droid', agentArgs: '', agentEnv: {} } - }) - await vi.advanceTimersByTimeAsync(300) - await flushAsyncTicks(20) - // Launch metadata starts confirmation but is never byte-routing authority. - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - isVisibleRef.current = true - binding.noteVisibilityResume() - await advanceVisibleForegroundRead() - await vi.advanceTimersByTimeAsync(WRAPPER_RESOLVE_RETRY_MS) - await vi.advanceTimersByTimeAsync(SECOND_WRAPPER_RETRY_MS) - - expect(foregroundReadCallsFor(ptyId).length).toBeGreaterThanOrEqual(3) - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: null, - shellForeground: true - }) - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(cacheKey) - - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: null, - shellForeground: false - } - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - - vi.clearAllTimers() - const settledReadCount = foregroundReadCallsFor(ptyId).length - binding.sampleForegroundAgentOnFocus() - await vi.advanceTimersByTimeAsync( - VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS - ) - expect(foregroundReadCallsFor(ptyId)).toHaveLength(settledReadCount) - }) - - it('does not sample when a live hook row already supplies pane identity', async () => { - vi.useFakeTimers() - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const ptyId = 'pty-hook-identity-no-sample' - const tabId = `tab-${ptyId}` - mockStoreState.agentStatusByPaneKey[makePaneKey(tabId, LEAF_1)] = { - state: 'working', - agentType: 'codex' - } - - await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) - await advanceVisibleForegroundRead() - - expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) - }) - - it('does not sample when process identity is already known', async () => { - vi.useFakeTimers() - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const ptyId = 'pty-process-identity-no-sample' - const tabId = `tab-${ptyId}` - mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { - agent: 'codex', - routingTrusted: true, - shellForeground: false - } - - await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) - await advanceVisibleForegroundRead() - - expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) - }) - - it('does not re-sample once 133;D proved the pane is at a shell prompt', async () => { - vi.useFakeTimers() - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const ptyId = 'pty-shell-foreground-no-sample' - const tabId = `tab-${ptyId}` - mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { - agent: null, - shellForeground: true - } - - await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) - await advanceVisibleForegroundRead() - - expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) - }) - - it('does not re-sample a shell-confirmed pane from stale launch metadata', async () => { - vi.useFakeTimers() - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const ptyId = 'pty-shell-foreground-launch-agent-sample' - const tabId = `tab-${ptyId}` - mockStoreState.tabsByWorktree = { - 'wt-1': [{ id: tabId, ptyId, launchAgent: 'codex' }] - } - mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { - agent: null, - shellForeground: true - } - - const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) - await advanceVisibleForegroundRead() - - expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) - expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalledWith( - cacheKey, - expect.anything() - ) - }) - - it('fails closed when a leaked 133;D cancels identityless recovery', async () => { - // Why: a cached visible read has no routing authority; on a racing command boundary, trust the shell marker over stale identity. - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const ptyId = 'pty-reattach-idle-codex-leaked-d' - const tabId = `tab-${ptyId}` - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation( - async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks?.onData ?? null - return sessionId ? { id: sessionId } : null - } - ) - transportFactoryQueue.push(transport) - const deps = createDeps({ - tabId, - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: ptyId } - }) - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks(20) - - const cacheKey = makePaneKey(tabId, LEAF_1) - dataCallbackRef.current?.('\x1b]133;D;0\x07') - - await advanceVisibleForegroundRead() - - expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { - agent: null, - shellForeground: true - }) - expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) - }) - - it('re-confirms leaked 133;D after detach moved pane-scoped Droid identity', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('droid') - const ptyId = 'pty-detached-droid-leaked-d' - const tabId = 'tab-detached-droid' - const cacheKey = makePaneKey(tabId, LEAF_1) - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation( - async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks?.onData ?? null - return sessionId ? { id: sessionId } : null - } - ) - transportFactoryQueue.push(transport) - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: 'droid', - shellForeground: false - } - mockStoreState.agentLaunchConfigByPaneKey[cacheKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'droid' } - } - const deps = createDeps({ - tabId, - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: ptyId } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks(20) - dataCallbackRef.current?.('\x1b]133;D;0\x07') - await advanceVisibleForegroundRead() - - expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) - expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalledWith(cacheKey, { - agent: null, - shellForeground: true - }) - expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { - agent: 'droid', - agentEvidence: 'process-read', - routingTrusted: true, - shellForeground: false - }) - }) - - it('never probes the foreground for a visible remote/SSH restored pane', async () => { - // Why: foreground reads are local-only (expensive RPCs); keep remote/SSH panes off the recovery probe. - vi.useFakeTimers() - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const ptyId = 'remote:web-env-1@@pty-remote-idle-agent' - - await connectRestoredPaneForForegroundSampling({ ptyId, tabId: 'tab-remote-idle-agent' }) - await advanceVisibleForegroundRead() - - expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) - }) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts new file mode 100644 index 00000000000..f6ef9824c44 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent.test.ts @@ -0,0 +1,25 @@ +import { describe } from 'vitest' +import { registerPtyConnectionForegroundAgentRoutingTests } from './pty-connection-foreground-agent-routing.spec' +import { registerPtyConnectionForegroundAgentSamplingTests } from './pty-connection-foreground-agent-sampling.spec' +import { registerPtyConnectionForegroundProcessExitTests } from './pty-connection-foreground-process-exit.spec' +import { registerPtyConnectionForegroundRoutingConfirmationTests } from './pty-connection-foreground-routing-confirmation.spec' +import { registerPtyConnectionForegroundWritePathTests } from './pty-connection-foreground-write-path.spec' +import { registerPtyConnectionInterruptInferenceTests } from './pty-connection-interrupt-inference.spec' +import { registerPtyConnectionTypedAgentIdentityTests } from './pty-connection-typed-agent-identity.spec' + +describe( + 'pty-connection-foreground-agent-routing', + registerPtyConnectionForegroundAgentRoutingTests +) +describe( + 'pty-connection-foreground-agent-sampling', + registerPtyConnectionForegroundAgentSamplingTests +) +describe('pty-connection-foreground-process-exit', registerPtyConnectionForegroundProcessExitTests) +describe( + 'pty-connection-foreground-routing-confirmation', + registerPtyConnectionForegroundRoutingConfirmationTests +) +describe('pty-connection-foreground-write-path', registerPtyConnectionForegroundWritePathTests) +describe('pty-connection-interrupt-inference', registerPtyConnectionInterruptInferenceTests) +describe('pty-connection-typed-agent-identity', registerPtyConnectionTypedAgentIdentityTests) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.spec.ts new file mode 100644 index 00000000000..9d796f0879e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.spec.ts @@ -0,0 +1,117 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionForegroundProcessExitTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty process-exit retirement', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + async function connectRestoredPane( + ptyId: string, + visible: boolean + ): Promise<{ cacheKey: string; binding: { sampleForegroundAgentOnFocus: () => void } }> { + const { connectPanePty } = await import('./pty-connection') + const tabId = `tab-${ptyId}` + const transport = createMockTransport(ptyId) + transport.getPtyId.mockImplementation(() => ptyId) + transport.connect.mockImplementation(async () => null) + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId }, + isVisibleRef: { current: visible } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: connectPanePty returns the pane binding, which exposes sampleForegroundAgentOnFocus. + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) as unknown as { sampleForegroundAgentOnFocus: () => void } + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + return { cacheKey: makePaneKey(tabId, LEAF_1), binding } + } + + it('retires a hand-typed Codex read in a pane without command marks once it exits', async () => { + vi.useFakeTimers() + const ptyId = 'pty-unmarked-codex-exit' + let foreground: string | null = 'codex' + let children = true + vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) + vi.mocked(window.api.pty.hasChildProcesses).mockImplementation(async () => children) + + const { cacheKey, binding } = await connectRestoredPane(ptyId, true) + await vi.advanceTimersByTimeAsync(3_000) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toMatchObject({ + agent: 'codex', + agentEvidence: 'process-read' + }) + + foreground = 'zsh' + children = false + await vi.advanceTimersByTimeAsync(10_000) + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: null, + shellForeground: false + }) + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(cacheKey) + + // Codex typed again in the same unmarked pane: the next sample must identify it. + foreground = 'codex' + children = true + binding.sampleForegroundAgentOnFocus() + await vi.advanceTimersByTimeAsync(3_000) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toMatchObject({ + agent: 'codex', + agentEvidence: 'process-read' + }) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts deleted file mode 100644 index 6611360729a..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty process-exit retirement', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - async function connectRestoredPane( - ptyId: string, - visible: boolean - ): Promise<{ cacheKey: string; binding: { sampleForegroundAgentOnFocus: () => void } }> { - const { connectPanePty } = await import('./pty-connection') - const tabId = `tab-${ptyId}` - const transport = createMockTransport(ptyId) - transport.getPtyId.mockImplementation(() => ptyId) - transport.connect.mockImplementation(async () => null) - transportFactoryQueue.push(transport) - const deps = createDeps({ - tabId, - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: ptyId }, - isVisibleRef: { current: visible } - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: connectPanePty returns the pane binding, which exposes sampleForegroundAgentOnFocus. - const binding = connectPanePty( - createPane(1) as never, - createManager(1) as never, - deps as never - ) as unknown as { sampleForegroundAgentOnFocus: () => void } - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks(20) - return { cacheKey: makePaneKey(tabId, LEAF_1), binding } - } - - it('retires a hand-typed Codex read in a pane without command marks once it exits', async () => { - vi.useFakeTimers() - const ptyId = 'pty-unmarked-codex-exit' - let foreground: string | null = 'codex' - let children = true - vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) - vi.mocked(window.api.pty.hasChildProcesses).mockImplementation(async () => children) - - const { cacheKey, binding } = await connectRestoredPane(ptyId, true) - await vi.advanceTimersByTimeAsync(3_000) - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toMatchObject({ - agent: 'codex', - agentEvidence: 'process-read' - }) - - foreground = 'zsh' - children = false - await vi.advanceTimersByTimeAsync(10_000) - - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: null, - shellForeground: false - }) - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(cacheKey) - - // Codex typed again in the same unmarked pane: the next sample must identify it. - foreground = 'codex' - children = true - binding.sampleForegroundAgentOnFocus() - await vi.advanceTimersByTimeAsync(3_000) - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toMatchObject({ - agent: 'codex', - agentEvidence: 'process-read' - }) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.spec.ts new file mode 100644 index 00000000000..339d32533f9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.spec.ts @@ -0,0 +1,290 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + VISIBLE_PTY_SETTLE_MS, + WRAPPER_RESOLVE_RETRY_MS, + SECOND_WRAPPER_RETRY_MS +} from './pty-connection-test-constants' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { + resolveMockPaneWindowsShiftEnterEncoding, + type StoreState +} from './pty-connection-test-store-state' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionForegroundRoutingConfirmationTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + describe('foreground routing confirmation authority', () => { + // Why: bindings share the tab-1/LEAF_1 pane key; give each case its own tabId so no other test's publish pollutes it. + async function connectRestoredPaneForForegroundSampling( + args: { + ptyId?: string + tabId?: string + isVisibleRef?: { current: boolean } + launchConfig?: { + agentCommand?: string + agentArgs: string + agentEnv: Record + } + launchAgent?: TuiAgent + } = {} + ): Promise<{ + binding: { + noteVisibilityResume: () => void + sampleForegroundAgentOnFocus: () => void + requestWindowsShiftEnterReconfirmation: () => void + } + deps: ReturnType + transport: MockTransport + cacheKey: string + }> { + const { connectPanePty } = await import('./pty-connection') + const ptyId = args.ptyId ?? 'tab-pty' + const tabId = args.tabId ?? `tab-${ptyId}` + const hasReattachMetadata = + args.launchConfig !== undefined || args.launchAgent !== undefined + const transport = createMockTransport(hasReattachMetadata ? null : ptyId) + let connectedPtyId: string | null = hasReattachMetadata ? null : ptyId + transport.getPtyId.mockImplementation(() => connectedPtyId) + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + connectedPtyId = sessionId ?? null + return sessionId + ? { + id: sessionId, + ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), + ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}) + } + : null + }) + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId }, + ...(args.isVisibleRef ? { isVisibleRef: args.isVisibleRef } : {}) + }) + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) as unknown as { + noteVisibilityResume: () => void + sampleForegroundAgentOnFocus: () => void + requestWindowsShiftEnterReconfirmation: () => void + } + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + return { binding, deps, transport, cacheKey: makePaneKey(tabId, LEAF_1) } + } + + async function advanceVisibleForegroundRead(): Promise { + await vi.advanceTimersByTimeAsync(VISIBLE_PTY_SETTLE_MS) + await flushAsyncTicks() + } + + it('does not retain routing capability when no confirmation read is scheduled', async () => { + vi.useFakeTimers() + const isVisibleRef = { current: false } + const ptyId = 'pty-pi-hidden-no-read' + const tabId = `tab-${ptyId}` + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + isVisibleRef + }) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'pi', + routingTrusted: true, + shellForeground: false + } + + // Hidden pane: sampleVisiblePaneForegroundAgent bails, so nothing would ever + // clear a pending flag. It must fail closed to the legacy encoding instead. + binding.requestWindowsShiftEnterReconfirmation() + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'pi', + routingRevoked: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + }) + + it('does not let a pending confirmation re-arm itself', async () => { + vi.useFakeTimers() + // Never resolves: the confirmation stays in flight for the whole test. + vi.mocked(window.api.pty.confirmForegroundProcess).mockReturnValue(new Promise(() => {})) + const ptyId = 'pty-pi-no-remint' + const tabId = `tab-${ptyId}` + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId + }) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'pi', + routingTrusted: true, + shellForeground: false + } + + binding.requestWindowsShiftEnterReconfirmation() + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() + expect( + mockStoreState.paneForegroundAgentByPaneKey[cacheKey]?.routingConfirmationPending + ).toBe(true) + const publishesAfterFirst = mockStoreState.setPaneForegroundAgent.mock.calls.length + + // A pending entry is not trusted evidence: further requests must not republish it. + binding.requestWindowsShiftEnterReconfirmation() + binding.requestWindowsShiftEnterReconfirmation() + await vi.advanceTimersByTimeAsync(1_000) + await flushAsyncTicks() + + expect(mockStoreState.setPaneForegroundAgent.mock.calls.length).toBe(publishesAfterFirst) + }) + + it('does not reauthorize explicitly revoked routing before confirmation', async () => { + vi.useFakeTimers() + const ptyId = 'pty-pi-revoked-focus' + const tabId = `tab-${ptyId}` + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId + }) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'pi', + routingRevoked: true, + shellForeground: false + } + + binding.sampleForegroundAgentOnFocus() + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'pi', + routingRevoked: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + }) + + it.each(['null result', 'provider rejection'] as const)( + 'drops pending routing after an inconclusive %s', + async (outcome) => { + vi.useFakeTimers() + if (outcome === 'provider rejection') { + vi.mocked(window.api.pty.confirmForegroundProcess).mockRejectedValue( + new Error('inspection unavailable') + ) + } else { + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) + } + const ptyId = `pty-pi-reconfirm-${outcome.replace(' ', '-')}` + const tabId = `tab-${ptyId}` + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId + }) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'pi', + routingTrusted: true, + shellForeground: false + } + + binding.sampleForegroundAgentOnFocus() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u') + + await vi.advanceTimersByTimeAsync( + VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + ) + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'pi', + routingRevoked: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe( + 'alt-enter' + ) + } + ) + + it('replaces pending Pi routing when confirmation finds another agent', async () => { + vi.useFakeTimers() + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('codex') + const ptyId = 'pty-pi-reconfirm-codex' + const tabId = `tab-${ptyId}` + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId + }) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'pi', + routingTrusted: true, + shellForeground: false + } + + binding.sampleForegroundAgentOnFocus() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u') + + await advanceVisibleForegroundRead() + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'codex', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + }) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts deleted file mode 100644 index c93ef65df38..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - VISIBLE_PTY_SETTLE_MS, - WRAPPER_RESOLVE_RETRY_MS, - SECOND_WRAPPER_RETRY_MS -} from './pty-connection-test-constants' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { - resolveMockPaneWindowsShiftEnterEncoding, - type StoreState -} from './pty-connection-test-store-state' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - describe('foreground routing confirmation authority', () => { - // Why: bindings share the tab-1/LEAF_1 pane key; give each case its own tabId so no other test's publish pollutes it. - async function connectRestoredPaneForForegroundSampling( - args: { - ptyId?: string - tabId?: string - isVisibleRef?: { current: boolean } - launchConfig?: { - agentCommand?: string - agentArgs: string - agentEnv: Record - } - launchAgent?: TuiAgent - } = {} - ): Promise<{ - binding: { - noteVisibilityResume: () => void - sampleForegroundAgentOnFocus: () => void - requestWindowsShiftEnterReconfirmation: () => void - } - deps: ReturnType - transport: MockTransport - cacheKey: string - }> { - const { connectPanePty } = await import('./pty-connection') - const ptyId = args.ptyId ?? 'tab-pty' - const tabId = args.tabId ?? `tab-${ptyId}` - const hasReattachMetadata = args.launchConfig !== undefined || args.launchAgent !== undefined - const transport = createMockTransport(hasReattachMetadata ? null : ptyId) - let connectedPtyId: string | null = hasReattachMetadata ? null : ptyId - transport.getPtyId.mockImplementation(() => connectedPtyId) - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - connectedPtyId = sessionId ?? null - return sessionId - ? { - id: sessionId, - ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), - ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}) - } - : null - }) - transportFactoryQueue.push(transport) - const deps = createDeps({ - tabId, - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: ptyId }, - ...(args.isVisibleRef ? { isVisibleRef: args.isVisibleRef } : {}) - }) - const binding = connectPanePty( - createPane(1) as never, - createManager(1) as never, - deps as never - ) as unknown as { - noteVisibilityResume: () => void - sampleForegroundAgentOnFocus: () => void - requestWindowsShiftEnterReconfirmation: () => void - } - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks(20) - return { binding, deps, transport, cacheKey: makePaneKey(tabId, LEAF_1) } - } - - async function advanceVisibleForegroundRead(): Promise { - await vi.advanceTimersByTimeAsync(VISIBLE_PTY_SETTLE_MS) - await flushAsyncTicks() - } - - it('does not retain routing capability when no confirmation read is scheduled', async () => { - vi.useFakeTimers() - const isVisibleRef = { current: false } - const ptyId = 'pty-pi-hidden-no-read' - const tabId = `tab-${ptyId}` - const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ - ptyId, - tabId, - isVisibleRef - }) - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: 'pi', - routingTrusted: true, - shellForeground: false - } - - // Hidden pane: sampleVisiblePaneForegroundAgent bails, so nothing would ever - // clear a pending flag. It must fail closed to the legacy encoding instead. - binding.requestWindowsShiftEnterReconfirmation() - await vi.advanceTimersByTimeAsync(350) - await flushAsyncTicks() - - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'pi', - routingRevoked: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - }) - - it('does not let a pending confirmation re-arm itself', async () => { - vi.useFakeTimers() - // Never resolves: the confirmation stays in flight for the whole test. - vi.mocked(window.api.pty.confirmForegroundProcess).mockReturnValue(new Promise(() => {})) - const ptyId = 'pty-pi-no-remint' - const tabId = `tab-${ptyId}` - const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: 'pi', - routingTrusted: true, - shellForeground: false - } - - binding.requestWindowsShiftEnterReconfirmation() - await vi.advanceTimersByTimeAsync(350) - await flushAsyncTicks() - expect( - mockStoreState.paneForegroundAgentByPaneKey[cacheKey]?.routingConfirmationPending - ).toBe(true) - const publishesAfterFirst = mockStoreState.setPaneForegroundAgent.mock.calls.length - - // A pending entry is not trusted evidence: further requests must not republish it. - binding.requestWindowsShiftEnterReconfirmation() - binding.requestWindowsShiftEnterReconfirmation() - await vi.advanceTimersByTimeAsync(1_000) - await flushAsyncTicks() - - expect(mockStoreState.setPaneForegroundAgent.mock.calls.length).toBe(publishesAfterFirst) - }) - - it('does not reauthorize explicitly revoked routing before confirmation', async () => { - vi.useFakeTimers() - const ptyId = 'pty-pi-revoked-focus' - const tabId = `tab-${ptyId}` - const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: 'pi', - routingRevoked: true, - shellForeground: false - } - - binding.sampleForegroundAgentOnFocus() - - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'pi', - routingRevoked: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - }) - - it.each(['null result', 'provider rejection'] as const)( - 'drops pending routing after an inconclusive %s', - async (outcome) => { - vi.useFakeTimers() - if (outcome === 'provider rejection') { - vi.mocked(window.api.pty.confirmForegroundProcess).mockRejectedValue( - new Error('inspection unavailable') - ) - } else { - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) - } - const ptyId = `pty-pi-reconfirm-${outcome.replace(' ', '-')}` - const tabId = `tab-${ptyId}` - const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ - ptyId, - tabId - }) - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: 'pi', - routingTrusted: true, - shellForeground: false - } - - binding.sampleForegroundAgentOnFocus() - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u') - - await vi.advanceTimersByTimeAsync( - VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS - ) - await flushAsyncTicks() - - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'pi', - routingRevoked: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - } - ) - - it('replaces pending Pi routing when confirmation finds another agent', async () => { - vi.useFakeTimers() - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('codex') - const ptyId = 'pty-pi-reconfirm-codex' - const tabId = `tab-${ptyId}` - const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) - mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { - agent: 'pi', - routingTrusted: true, - shellForeground: false - } - - binding.sampleForegroundAgentOnFocus() - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u') - - await advanceVisibleForegroundRead() - - expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ - agent: 'codex', - agentEvidence: 'process-read', - routingTrusted: true, - shellForeground: false - }) - expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - }) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.spec.ts new file mode 100644 index 00000000000..9fb2332b8ff --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.spec.ts @@ -0,0 +1,488 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + temporarilySetNavigatorUserAgent, + sendTerminalInputThroughPane +} from './pty-connection-test-dom' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionForegroundWritePathTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('queues visible bulk output off the synchronous xterm write path', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + } + ) + transportFactoryQueue.push(transport) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + vi.useFakeTimers() + capturedDataCallback.current?.('x'.repeat(16 * 1024)) + + expect(pane.terminal.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(0) + expect(pane.terminal.write).toHaveBeenCalledWith('x'.repeat(16 * 1024), expect.any(Function)) + }) + + it('keeps ANSI redraws after terminal input on the immediate xterm write path', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + } + ) + transportFactoryQueue.push(transport) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + const onDataMock = pane.terminal.onData as unknown as { + mock: { calls: [[(data: string) => void] | []] } + } + const terminalInputHandler = onDataMock.mock.calls[0]?.[0] + expect(terminalInputHandler).toBeTypeOf('function') + terminalInputHandler?.('a') + + const redraw = `\x1b[2J\x1b[H${'codex composer redraw '.repeat(200)}` + capturedDataCallback.current?.(redraw) + + expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) + }) + + it('keeps large ANSI redraws after terminal input on the immediate xterm write path', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + } + ) + transportFactoryQueue.push(transport) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + const onDataMock = pane.terminal.onData as unknown as { + mock: { calls: [[(data: string) => void] | []] } + } + const terminalInputHandler = onDataMock.mock.calls[0]?.[0] + expect(terminalInputHandler).toBeTypeOf('function') + terminalInputHandler?.('a') + + const redraw = `\x1b[2J\x1b[H${'codex large composer redraw '.repeat(1_200)}` + expect(redraw.length).toBeGreaterThan(16 * 1024) + expect(redraw.length).toBeLessThan(128 * 1024) + capturedDataCallback.current?.(redraw) + + expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) + }) + + it('routes terminal input through deferPtyInput so the host can withhold it', async () => { + // Regression: inlining forwardPtyInput into onData dropped this hop, silently disabling link-click mouse suppression. + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + transportFactoryQueue.push(transport) + const deferPtyInput = vi.fn() + + connectPanePty( + pane as never, + createManager(1) as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The dependency fixture provides the input-forwarding hooks exercised by this test. + createDeps({ deferPtyInput }) as never + ) + await flushAsyncTicks() + sendTerminalInputThroughPane(pane, 'a') + + expect(deferPtyInput).toHaveBeenCalledWith(1, 'a', expect.any(Function)) + expect(transport.sendInput).not.toHaveBeenCalled() + + const forward = deferPtyInput.mock.calls[0]?.[2] as (data: string) => void + forward('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') + }) + + it('forwards terminal input directly when the host supplies no deferPtyInput', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + transportFactoryQueue.push(transport) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + sendTerminalInputThroughPane(pane, 'a') + + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') + }) + + it('keeps large ANSI redraws after captured shortcut input on the immediate path', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + } + ) + transportFactoryQueue.push(transport) + + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps() as never + ) as unknown as { markShortcutTerminalInputSent: () => void } + await flushAsyncTicks() + binding.markShortcutTerminalInputSent() + + const redraw = `\x1b[2J\x1b[H${'pi composer redraw '.repeat(200)}` + expect(redraw.length).toBeGreaterThan(2_048) + capturedDataCallback.current?.(redraw) + + expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) + }) + + it('does not let OpenTUI-style small ANSI redraw bursts monopolize foreground writes', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + } + ) + transportFactoryQueue.push(transport) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + vi.useFakeTimers() + + const frames = Array.from({ length: 270 }, (_, index) => + [ + '\x1b[?2026h', + '\x1b[?25l', + `\x1b[2;3H\x1b[38;2;255;138;0m${index % 2 === 0 ? '#' : '*'}${'*'.repeat(7)}\x1b[0m`, + `\x1b[2;12H\x1b[38;2;255;138;0mOpenTUI synthetic active TUI redraw ${index}\x1b[0m`, + `\x1b[4;6H\x1b[38;2;231;237;247m${'#'.repeat(36)} ${'opentui'.repeat(48)}\x1b[0m` + ].join('') + ) + expect(frames.every((frame) => frame.length <= 2048 && frame.includes('\x1b['))).toBe(true) + expect(frames.join('').length).toBeGreaterThan(128 * 1024) + + for (const frame of frames) { + capturedDataCallback.current?.(frame) + } + + expect(pane.terminal.write.mock.calls.length).toBeLessThan(frames.length) + vi.advanceTimersByTime(0) + expect(pane.terminal.write.mock.calls.length).toBeGreaterThan(0) + }) + + it('drains a post-submit synchronized frame on the fast path when its end marker arrives late', async () => { + // Why (STA-1041): a submit-opened DEC 2026 frame must drain fast (~16ms) by open time, even when ConPTY splits its close past the 150ms window. + const restoreNavigator = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + vi.useFakeTimers() + // Enter submits; the synchronized repaint frame opens immediately after. + sendTerminalInputThroughPane(pane, '\r') + const repaintBody = 'opencode repaint '.repeat(200) + expect(repaintBody.length).toBeGreaterThan(2048) + capturedDataCallback.current?.(`\x1b[?2026h${repaintBody}`) + // The frame body holds, then its hold-safety fallback drains it. + vi.advanceTimersByTime(40) + pane.terminal.write.mockClear() + + // ConPTY delivers the closing chunk well past the 150ms redraw window. + vi.advanceTimersByTime(300) + const endChunk = `${repaintBody}\x1b[?25l\x1b[13;14H\x1b[?25h\x1b[?2026l` + expect(endChunk.length).toBeGreaterThan(2048) + capturedDataCallback.current?.(endChunk) + + // Fast path: the latency-sensitive coalesce window is ~16ms, not 1000ms. + vi.advanceTimersByTime(20) + expect(pane.terminal.write).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + restoreNavigator() + } + }) + + it('keeps coalescing a synchronized frame end with no recent input behind the 1s fallback', async () => { + // Why (STA-1041): fast path is keystroke-only; a background split redraw waits the full fallback so Windows never rasterizes the transient cursor. + const restoreNavigator = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + vi.useFakeTimers() + // No terminal input: this synchronized redraw is not submit-driven. + const repaintBody = 'opencode repaint '.repeat(200) + capturedDataCallback.current?.(`\x1b[?2026h${repaintBody}`) + // Let the frame body drain via its 250ms hold-safety fallback before isolating the closing chunk. + vi.advanceTimersByTime(300) + pane.terminal.write.mockClear() + + const endChunk = `${repaintBody}\x1b[?25l\x1b[13;14H\x1b[?25h\x1b[?2026l` + capturedDataCallback.current?.(endChunk) + + // The fast 16ms window must NOT flush a background split-restore frame. + vi.advanceTimersByTime(20) + expect(pane.terminal.write).not.toHaveBeenCalled() + + // The full coalesce fallback still drains it so the frame is never lost. + vi.advanceTimersByTime(1000) + expect(pane.terminal.write).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + restoreNavigator() + } + }) + + it('clears the synchronized latch when ConPTY splits the frame end marker', async () => { + // Why: issue #8754 — a split \x1b[?2026l left the foreground latch armed, so every later + // chunk was held as frame body and the visible pane froze until the tab was blurred. + const restoreNavigator = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + vi.useFakeTimers() + const repaintBody = 'codex spinner '.repeat(200) + capturedDataCallback.current?.(`\x1b[?2026h${repaintBody}`) + vi.advanceTimersByTime(300) + pane.terminal.write.mockClear() + + // ConPTY splits the closing marker across two chunks. + capturedDataCallback.current?.(`${repaintBody}\x1b[?25l\x1b[13;14H\x1b[?25h\x1b[?202`) + capturedDataCallback.current?.('6l') + vi.advanceTimersByTime(1100) + expect(pane.terminal.write).toHaveBeenCalled() + pane.terminal.write.mockClear() + + // The frame is closed, so ordinary output must paint instead of being held as frame body. + capturedDataCallback.current?.('command finished\r\n') + vi.advanceTimersByTime(20) + expect(pane.terminal.write).toHaveBeenCalled() + } finally { + vi.useRealTimers() + restoreNavigator() + } + }) + + it('does not leak the interactive latch across a same-chunk close+open to a stale frame', async () => { + // Why: a same-chunk close+open re-evaluates the new frame from its own open time so it can't inherit the prior frame's fast path. + const restoreNavigator = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + vi.useFakeTimers() + // Load-bearing: Enter opens an INTERACTIVE frame that stays OPEN — the leak precondition, since the buggy set-branch is gated on !active. + sendTerminalInputThroughPane(pane, '\r') + const repaintBody = 'opencode repaint '.repeat(200) + expect(repaintBody.length).toBeGreaterThan(2048) + capturedDataCallback.current?.(`\x1b[?2026h${repaintBody}`) + // Move past the 400ms interactive window without closing the frame, so any new frame now classifies non-interactive. + vi.advanceTimersByTime(500) + pane.terminal.write.mockClear() + + // One chunk closes the prior frame and opens a new one ~500ms post-keystroke; the recompute must judge the new frame non-interactive from its own open time. + capturedDataCallback.current?.(`\x1b[?2026l\x1b[?2026h${repaintBody}`) + vi.advanceTimersByTime(40) + pane.terminal.write.mockClear() + + // The new frame's split restore + end marker arrive in a later chunk. + const staleEndChunk = `${repaintBody}\x1b[?25l\x1b[13;14H\x1b[?25h\x1b[?2026l` + expect(staleEndChunk.length).toBeGreaterThan(2048) + capturedDataCallback.current?.(staleEndChunk) + + // The fast ~16ms window must NOT flush this stale non-interactive frame. + vi.advanceTimersByTime(20) + expect(pane.terminal.write).not.toHaveBeenCalled() + + // Only the full 1s coalesce fallback drains it, restoring the protection. + vi.advanceTimersByTime(1000) + expect(pane.terminal.write).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + restoreNavigator() + } + }) + + it('coalesces a second synchronized frame that opens after the window with no keystroke', async () => { + // Why: the second frame opens after the interactive window, so its start is judged independently and stays on the 1s fallback. + const restoreNavigator = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + vi.useFakeTimers() + // Frame 1: submit-driven and interactive; opens and closes cleanly. + sendTerminalInputThroughPane(pane, '\r') + const repaintBody = 'opencode repaint '.repeat(200) + expect(repaintBody.length).toBeGreaterThan(2048) + capturedDataCallback.current?.(`\x1b[?2026h${repaintBody}\x1b[?2026l`) + vi.advanceTimersByTime(40) + + // Move past the 400ms window with no further keystroke, then open frame 2. + vi.advanceTimersByTime(500) + capturedDataCallback.current?.(`\x1b[?2026h${repaintBody}`) + vi.advanceTimersByTime(40) + pane.terminal.write.mockClear() + + // Frame 2's split cursor restore + end marker arrive in a later chunk. + const secondEndChunk = `${repaintBody}\x1b[?25l\x1b[13;14H\x1b[?25h\x1b[?2026l` + capturedDataCallback.current?.(secondEndChunk) + + // The fast ~16ms window must NOT flush this non-interactive second frame. + vi.advanceTimersByTime(20) + expect(pane.terminal.write).not.toHaveBeenCalled() + + // The full 1s coalesce fallback drains it so the frame is never lost. + vi.advanceTimersByTime(1000) + expect(pane.terminal.write).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + restoreNavigator() + } + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts deleted file mode 100644 index 9975a9b330a..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts +++ /dev/null @@ -1,564 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - temporarilySetNavigatorUserAgent, - sendTerminalInputThroughPane -} from './pty-connection-test-dom' -import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('queues visible bulk output off the synchronous xterm write path', async () => { - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const transport = createMockTransport('pty-1') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-1' - }) - transportFactoryQueue.push(transport) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - - vi.useFakeTimers() - capturedDataCallback.current?.('x'.repeat(16 * 1024)) - - expect(pane.terminal.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(0) - expect(pane.terminal.write).toHaveBeenCalledWith('x'.repeat(16 * 1024), expect.any(Function)) - }) - - it('keeps ANSI redraws after terminal input on the immediate xterm write path', async () => { - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const transport = createMockTransport('pty-1') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-1' - }) - transportFactoryQueue.push(transport) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - const onDataMock = pane.terminal.onData as unknown as { - mock: { calls: [[(data: string) => void] | []] } - } - const terminalInputHandler = onDataMock.mock.calls[0]?.[0] - expect(terminalInputHandler).toBeTypeOf('function') - terminalInputHandler?.('a') - - const redraw = `\x1b[2J\x1b[H${'codex composer redraw '.repeat(200)}` - capturedDataCallback.current?.(redraw) - - expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) - }) - - it('keeps large ANSI redraws after terminal input on the immediate xterm write path', async () => { - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const transport = createMockTransport('pty-1') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-1' - }) - transportFactoryQueue.push(transport) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - const onDataMock = pane.terminal.onData as unknown as { - mock: { calls: [[(data: string) => void] | []] } - } - const terminalInputHandler = onDataMock.mock.calls[0]?.[0] - expect(terminalInputHandler).toBeTypeOf('function') - terminalInputHandler?.('a') - - const redraw = `\x1b[2J\x1b[H${'codex large composer redraw '.repeat(1_200)}` - expect(redraw.length).toBeGreaterThan(16 * 1024) - expect(redraw.length).toBeLessThan(128 * 1024) - capturedDataCallback.current?.(redraw) - - expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) - }) - - it('routes terminal input through deferPtyInput so the host can withhold it', async () => { - // Regression: inlining forwardPtyInput into onData dropped this hop, silently disabling link-click mouse suppression. - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const transport = createMockTransport('pty-1') - transportFactoryQueue.push(transport) - const deferPtyInput = vi.fn() - - connectPanePty(pane as never, createManager(1) as never, createDeps({ deferPtyInput }) as never) - await flushAsyncTicks() - sendTerminalInputThroughPane(pane, 'a') - - expect(deferPtyInput).toHaveBeenCalledWith(1, 'a', expect.any(Function)) - expect(transport.sendInput).not.toHaveBeenCalled() - - const forward = deferPtyInput.mock.calls[0]?.[2] as (data: string) => void - forward('a') - expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') - }) - - it('forwards terminal input directly when the host supplies no deferPtyInput', async () => { - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const transport = createMockTransport('pty-1') - transportFactoryQueue.push(transport) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - sendTerminalInputThroughPane(pane, 'a') - - expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') - }) - - it('keeps large ANSI redraws after captured shortcut input on the immediate path', async () => { - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const transport = createMockTransport('pty-1') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-1' - }) - transportFactoryQueue.push(transport) - - const binding = connectPanePty( - pane as never, - createManager(1) as never, - createDeps() as never - ) as unknown as { markShortcutTerminalInputSent: () => void } - await flushAsyncTicks() - binding.markShortcutTerminalInputSent() - - const redraw = `\x1b[2J\x1b[H${'pi composer redraw '.repeat(200)}` - expect(redraw.length).toBeGreaterThan(2_048) - capturedDataCallback.current?.(redraw) - - expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) - }) - - it('does not let OpenTUI-style small ANSI redraw bursts monopolize foreground writes', async () => { - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const transport = createMockTransport('pty-1') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-1' - }) - transportFactoryQueue.push(transport) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - - vi.useFakeTimers() - - const frames = Array.from({ length: 270 }, (_, index) => - [ - '\x1b[?2026h', - '\x1b[?25l', - `\x1b[2;3H\x1b[38;2;255;138;0m${index % 2 === 0 ? '#' : '*'}${'*'.repeat(7)}\x1b[0m`, - `\x1b[2;12H\x1b[38;2;255;138;0mOpenTUI synthetic active TUI redraw ${index}\x1b[0m`, - `\x1b[4;6H\x1b[38;2;231;237;247m${'#'.repeat(36)} ${'opentui'.repeat(48)}\x1b[0m` - ].join('') - ) - expect(frames.every((frame) => frame.length <= 2048 && frame.includes('\x1b['))).toBe(true) - expect(frames.join('').length).toBeGreaterThan(128 * 1024) - - for (const frame of frames) { - capturedDataCallback.current?.(frame) - } - - expect(pane.terminal.write.mock.calls.length).toBeLessThan(frames.length) - vi.advanceTimersByTime(0) - expect(pane.terminal.write.mock.calls.length).toBeGreaterThan(0) - }) - - it('drains a post-submit synchronized frame on the fast path when its end marker arrives late', async () => { - // Why (STA-1041): a submit-opened DEC 2026 frame must drain fast (~16ms) by open time, even when ConPTY splits its close past the 150ms window. - const restoreNavigator = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - vi.useFakeTimers() - // Enter submits; the synchronized repaint frame opens immediately after. - sendTerminalInputThroughPane(pane, '\r') - const repaintBody = 'opencode repaint '.repeat(200) - expect(repaintBody.length).toBeGreaterThan(2048) - capturedDataCallback.current?.(`\x1b[?2026h${repaintBody}`) - // The frame body holds, then its hold-safety fallback drains it. - vi.advanceTimersByTime(40) - pane.terminal.write.mockClear() - - // ConPTY delivers the closing chunk well past the 150ms redraw window. - vi.advanceTimersByTime(300) - const endChunk = `${repaintBody}\x1b[?25l\x1b[13;14H\x1b[?25h\x1b[?2026l` - expect(endChunk.length).toBeGreaterThan(2048) - capturedDataCallback.current?.(endChunk) - - // Fast path: the latency-sensitive coalesce window is ~16ms, not 1000ms. - vi.advanceTimersByTime(20) - expect(pane.terminal.write).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - restoreNavigator() - } - }) - - it('keeps coalescing a synchronized frame end with no recent input behind the 1s fallback', async () => { - // Why (STA-1041): fast path is keystroke-only; a background split redraw waits the full fallback so Windows never rasterizes the transient cursor. - const restoreNavigator = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - vi.useFakeTimers() - // No terminal input: this synchronized redraw is not submit-driven. - const repaintBody = 'opencode repaint '.repeat(200) - capturedDataCallback.current?.(`\x1b[?2026h${repaintBody}`) - // Let the frame body drain via its 250ms hold-safety fallback before isolating the closing chunk. - vi.advanceTimersByTime(300) - pane.terminal.write.mockClear() - - const endChunk = `${repaintBody}\x1b[?25l\x1b[13;14H\x1b[?25h\x1b[?2026l` - capturedDataCallback.current?.(endChunk) - - // The fast 16ms window must NOT flush a background split-restore frame. - vi.advanceTimersByTime(20) - expect(pane.terminal.write).not.toHaveBeenCalled() - - // The full coalesce fallback still drains it so the frame is never lost. - vi.advanceTimersByTime(1000) - expect(pane.terminal.write).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - restoreNavigator() - } - }) - - it('clears the synchronized latch when ConPTY splits the frame end marker', async () => { - // Why: issue #8754 — a split \x1b[?2026l left the foreground latch armed, so every later - // chunk was held as frame body and the visible pane froze until the tab was blurred. - const restoreNavigator = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - vi.useFakeTimers() - const repaintBody = 'codex spinner '.repeat(200) - capturedDataCallback.current?.(`\x1b[?2026h${repaintBody}`) - vi.advanceTimersByTime(300) - pane.terminal.write.mockClear() - - // ConPTY splits the closing marker across two chunks. - capturedDataCallback.current?.(`${repaintBody}\x1b[?25l\x1b[13;14H\x1b[?25h\x1b[?202`) - capturedDataCallback.current?.('6l') - vi.advanceTimersByTime(1100) - expect(pane.terminal.write).toHaveBeenCalled() - pane.terminal.write.mockClear() - - // The frame is closed, so ordinary output must paint instead of being held as frame body. - capturedDataCallback.current?.('command finished\r\n') - vi.advanceTimersByTime(20) - expect(pane.terminal.write).toHaveBeenCalled() - } finally { - vi.useRealTimers() - restoreNavigator() - } - }) - - it('does not leak the interactive latch across a same-chunk close+open to a stale frame', async () => { - // Why: a same-chunk close+open re-evaluates the new frame from its own open time so it can't inherit the prior frame's fast path. - const restoreNavigator = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - vi.useFakeTimers() - // Load-bearing: Enter opens an INTERACTIVE frame that stays OPEN — the leak precondition, since the buggy set-branch is gated on !active. - sendTerminalInputThroughPane(pane, '\r') - const repaintBody = 'opencode repaint '.repeat(200) - expect(repaintBody.length).toBeGreaterThan(2048) - capturedDataCallback.current?.(`\x1b[?2026h${repaintBody}`) - // Move past the 400ms interactive window without closing the frame, so any new frame now classifies non-interactive. - vi.advanceTimersByTime(500) - pane.terminal.write.mockClear() - - // One chunk closes the prior frame and opens a new one ~500ms post-keystroke; the recompute must judge the new frame non-interactive from its own open time. - capturedDataCallback.current?.(`\x1b[?2026l\x1b[?2026h${repaintBody}`) - vi.advanceTimersByTime(40) - pane.terminal.write.mockClear() - - // The new frame's split restore + end marker arrive in a later chunk. - const staleEndChunk = `${repaintBody}\x1b[?25l\x1b[13;14H\x1b[?25h\x1b[?2026l` - expect(staleEndChunk.length).toBeGreaterThan(2048) - capturedDataCallback.current?.(staleEndChunk) - - // The fast ~16ms window must NOT flush this stale non-interactive frame. - vi.advanceTimersByTime(20) - expect(pane.terminal.write).not.toHaveBeenCalled() - - // Only the full 1s coalesce fallback drains it, restoring the protection. - vi.advanceTimersByTime(1000) - expect(pane.terminal.write).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - restoreNavigator() - } - }) - - it('coalesces a second synchronized frame that opens after the window with no keystroke', async () => { - // Why: the second frame opens after the interactive window, so its start is judged independently and stays on the 1s fallback. - const restoreNavigator = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - vi.useFakeTimers() - // Frame 1: submit-driven and interactive; opens and closes cleanly. - sendTerminalInputThroughPane(pane, '\r') - const repaintBody = 'opencode repaint '.repeat(200) - expect(repaintBody.length).toBeGreaterThan(2048) - capturedDataCallback.current?.(`\x1b[?2026h${repaintBody}\x1b[?2026l`) - vi.advanceTimersByTime(40) - - // Move past the 400ms window with no further keystroke, then open frame 2. - vi.advanceTimersByTime(500) - capturedDataCallback.current?.(`\x1b[?2026h${repaintBody}`) - vi.advanceTimersByTime(40) - pane.terminal.write.mockClear() - - // Frame 2's split cursor restore + end marker arrive in a later chunk. - const secondEndChunk = `${repaintBody}\x1b[?25l\x1b[13;14H\x1b[?25h\x1b[?2026l` - capturedDataCallback.current?.(secondEndChunk) - - // The fast ~16ms window must NOT flush this non-interactive second frame. - vi.advanceTimersByTime(20) - expect(pane.terminal.write).not.toHaveBeenCalled() - - // The full 1s coalesce fallback drains it so the frame is never lost. - vi.advanceTimersByTime(1000) - expect(pane.terminal.write).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - restoreNavigator() - } - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts new file mode 100644 index 00000000000..92dafe139c6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts @@ -0,0 +1,509 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { flushAsyncTicks } from './pty-connection-test-async' +import { sendTerminalInputThroughPane } from './pty-connection-test-dom' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionFreshSpawnGuardsTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('does not surface the zero-dimensions diagnostic for a hidden pane', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.cols = 0 + pane.terminal.rows = 0 + const deps = createDeps({ isVisibleRef: { current: false } }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + }) + + it('still surfaces the zero-dimensions diagnostic for a visible pane', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.cols = 0 + pane.terminal.rows = 0 + const deps = createDeps({ isVisibleRef: { current: true } }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith( + pane.id, + expect.stringContaining('Terminal has zero dimensions (0×0)') + ) + }) + + // Why: deleting a worktree kills its PTYs for the filesystem teardown; the + // renderer must not race a doomed respawn into a directory main is deleting + // (main fences it with TerminalRemovalInProgressError and the pane is about to + // unmount). See docs — bad UI was the raw fence error flashing on the tab. + it('skips a fresh spawn while the pane worktree is being deleted', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + deleteStateByWorktreeId: { 'wt-1': { isDeleting: true, phase: 'deleting' } } + } + // Why: a unique tab id keeps this pane's key clear of other tests' pendingSpawnByPaneKey entries so the connect deterministically fresh-spawns. + const deps = createDeps({ tabId: 'tab-removal-skip-spawn' }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + }) + + // The disposed-spawn kill in ipc-pty-connect asks this callback before retiring a PTY; it must + // answer from the live store, or a remounted pane's shell dies under it. + it('lets a disposed spawn survive while the pane surface still exists in the store', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const deps = createDeps({ tabId: 'tab-retain-disposed-spawn' }) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-retain-disposed-spawn', ptyId: null }] } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + const retain = createdTransportOptions[0]?.retainDisposedSpawn + if (typeof retain !== 'function') { + throw new Error('pane transport was built without retainDisposedSpawn') + } + expect(retain()).toBe(true) + mockStoreState = { + ...mockStoreState, + deleteStateByWorktreeId: { 'wt-1': { isDeleting: true, phase: 'deleting' } } + } + expect(retain()).toBe(false) + mockStoreState = { + ...mockStoreState, + deleteStateByWorktreeId: { 'local|wt-1': { isDeleting: true, phase: 'deleting' } } + } + expect(retain()).toBe(false) + mockStoreState = { + ...mockStoreState, + deleteStateByWorktreeId: {}, + tabsByWorktree: { 'wt-1': [] } + } + expect(retain()).toBe(false) + }) + + it('carries the pane placement onto its transport', async () => { + const { connectPanePty } = await import('./pty-connection') + transportFactoryQueue.push(createMockTransport()) + const placement = { kind: 'new-tab' } as const + const deps = createDeps({ tabId: 'tab-placement', placement }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(createdTransportOptions[0]).toMatchObject({ placement }) + }) + + it('fresh-spawns normally when the pane worktree is not being deleted', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + // Why: unique tab id → deterministic fresh spawn (mirrors the skip test's control). + const deps = createDeps({ tabId: 'tab-removal-control-spawn' }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalled() + }) + + // Why: a doomed pane (or a child pane whose parent worktree is being removed, + // which startFreshSpawn's own-worktree skip cannot see) can still race a spawn + // that main fences. reportError must swallow that fence so the tab never flashes + // the raw "Terminal cannot start while the worktree is being removed" banner. + it('swallows a worktree-removal fence error instead of surfacing it', async () => { + const { connectPanePty } = await import('./pty-connection') + const { TERMINAL_REMOVAL_IN_PROGRESS_MESSAGE } = + await import('../../../../shared/worktree/removal-fence-error') + const transport = createMockTransport() + const capturedOnError: { current: ((message: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedOnError.current = callbacks.onError ?? null + return 'pty-1' + } + ) + transportFactoryQueue.push(transport) + const deps = createDeps({ tabId: 'tab-fence-swallow' }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + // Why: assert the callback was captured before invoking — optional invocation + // would let this test false-pass (not.toHaveBeenCalled trivially true) if the + // transport onError wiring ever broke, exercising no suppression at all. + expect(capturedOnError.current).toBeTypeOf('function') + // Electron wraps the rejected ipcMain error with its own prefix; still swallowed. + capturedOnError.current!( + `Error invoking remote method 'pty:spawn': Error: ${TERMINAL_REMOVAL_IN_PROGRESS_MESSAGE}` + ) + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + }) + + it('still surfaces non-fence spawn errors through the pane error sink', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedOnError: { current: ((message: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedOnError.current = callbacks.onError ?? null + return 'pty-1' + } + ) + transportFactoryQueue.push(transport) + const deps = createDeps({ tabId: 'tab-real-error-surface' }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(capturedOnError.current).toBeTypeOf('function') + capturedOnError.current!('shell exited with code 1') + expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(1, 'shell exited with code 1') + }) + + it('threads the resolved local project runtime into IPC terminal transport options', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + settings: { + ...mockStoreState.settings, + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } + }, + projects: [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'windows-host' } }] + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + + expect(createdTransportOptions[0]?.projectRuntime).toEqual({ + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo1', + reason: 'project-override', + cacheKey: 'repo1:windows-host' + } + }) + }) + + it('keeps an explicit host fallback out of the project runtime', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null, forceHostRuntime: true }] + }, + settings: { + ...mockStoreState.settings, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + + expect(createdTransportOptions[0]?.projectRuntime).toBeUndefined() + }) + + it('spawns a Floating agent on native Windows beside an active WSL project', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createIpcPtyTransport } = await import('./pty-transport') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-1', + tabsByWorktree: { + ...mockStoreState.tabsByWorktree, + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { id: 'tab-floating-agent', ptyId: null, launchAgent: 'codex' } + ] + }, + projects: [ + { id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } + ], + settings: { + ...mockStoreState.settings, + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + tabId: 'tab-floating-agent', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + cwd: 'C:\\Users\\alice', + startup: { launchAgent: 'codex' } + }) as never + ) + await flushAsyncTicks() + + expect(createIpcPtyTransport).toHaveBeenCalledOnce() + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + expect(createdTransportOptions[0]).toMatchObject({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + executionHostId: 'local', + launchAgent: 'codex' + }) + expect(createdTransportOptions[0]?.projectRuntime).toBeUndefined() + }) + + it('observes live terminal GitHub PR URLs before agent completion', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + } + ) + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + + capturedDataCallback.current?.('Created https://github.com/acme/orca/pull/42\r\n') + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca', host: 'github.com' }, + number: 42 + }), + expect.objectContaining({ tabId: 'tab-1' }) + ) + }) + + it('surfaces an actionable error when a Codex backfill timeout drops to the shell', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-codex-backfill-timeout' + } + ) + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId: 'tab-codex-backfill-timeout', + startup: { command: 'codex', launchAgent: 'codex' } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + capturedDataCallback.current?.('timed out waiting for state db back') + capturedDataCallback.current?.('fill\r\n') + + expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith( + 1, + expect.stringContaining('Orca attempts background recovery for managed local and WSL homes') + ) + }) + + it('drops keystrokes while the replay guard is engaged, then forwards once it releases', async () => { + // Regression (cold-restore reattach lockout): a stuck replay guard dropped every keystroke ("can't type after reconnecting"); engaged guard suppresses input, released forwards it. + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('ssh:ssh-1@@pty-1') + transportFactoryQueue.push(transport) + const deps = createDeps() + + connectPanePty(pane as never, createManager(1, 1) as never, deps as never) + await flushAsyncTicks() + + transport.sendInput.mockClear() + // Engaged (as a stuck reattach would leave it): input must be suppressed. + deps.replayingPanesRef.current.set(pane.id, 3) + sendTerminalInputThroughPane(pane, 'echo hi\r') + expect(transport.sendInput).not.toHaveBeenCalled() + + // Released (via the guard's fallback or parse completion): input flows again. + deps.replayingPanesRef.current.delete(pane.id) + sendTerminalInputThroughPane(pane, 'echo hi\r') + expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r', 'query-reply') + }) + + it('preserves classified user input during replay while suppressing synthetic replies', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const userInputListeners = new Set<() => void>() + Object.assign(pane.terminal, { + _core: { + coreService: { + onUserInput: (listener: () => void) => { + userInputListeners.add(listener) + return { dispose: () => userInputListeners.delete(listener) } + } + } + } + }) + const transport = createMockTransport('ssh:ssh-1@@pty-1') + transportFactoryQueue.push(transport) + const deps = createDeps() + const deferred: (() => void)[] = [] + Object.assign(deps, { + deferPtyInput: (_paneId: number, data: string, forward: (data: string) => void) => { + deferred.push(() => forward(data)) + } + }) + connectPanePty(pane as never, createManager(1, 1) as never, deps as never) + await flushAsyncTicks() + transport.sendInput.mockClear() + deps.replayingPanesRef.current.set(pane.id, 1) + for (const listener of userInputListeners) { + listener() + } + sendTerminalInputThroughPane(pane, 'input_under_flood\r') + sendTerminalInputThroughPane(pane, '\x1b[?1;2c') + // A click on replayed scrollback that still has mouse tracking armed is user input to xterm, but must not reach the shell. + for (const listener of userInputListeners) { + listener() + } + sendTerminalInputThroughPane(pane, '\x1b[<0;12;4M') + for (const forward of deferred.splice(0)) { + forward() + } + expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r', 'driving') + + // A wheel over a replayed alt-screen frame becomes cursor keys; the fresh shell must not recall history from them. + pane.terminal.buffer.active.type = 'alternate' + for (const listener of userInputListeners) { + listener() + } + sendTerminalInputThroughPane(pane, '\x1b[B') + for (const forward of deferred.splice(0)) { + forward() + } + expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r', 'driving') + + // The same bytes on the normal buffer can only be a keyboard arrow, which survives replay. + pane.terminal.buffer.active.type = 'normal' + for (const listener of userInputListeners) { + listener() + } + sendTerminalInputThroughPane(pane, '\x1b[B') + for (const forward of deferred.splice(0)) { + forward() + } + expect(transport.sendInput).toHaveBeenCalledTimes(2) + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[B', 'driving') + + // Once the guard releases, the same mouse report is ordinary input again. + deps.replayingPanesRef.current.delete(pane.id) + for (const listener of userInputListeners) { + listener() + } + sendTerminalInputThroughPane(pane, '\x1b[<0;12;4M') + for (const forward of deferred.splice(0)) { + forward() + } + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[<0;12;4M', 'driving') + }) + + it('settles a queued startup only after the pane binds its spawned PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-resume') + transportFactoryQueue.push(transport) + const onStartupBound = vi.fn() + const startup = { + command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: { key: 'session_id', id: 'codex-session-1' } as const + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup, onStartupBound }) as never + ) + + expect(onStartupBound).not.toHaveBeenCalled() + expect(createdTransportOptions[0]).toMatchObject(startup) + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('pty-resume') + onPtySpawn?.('pty-resume') + + expect(onStartupBound).toHaveBeenCalledTimes(1) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts deleted file mode 100644 index f4741326971..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts +++ /dev/null @@ -1,590 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { flushAsyncTicks } from './pty-connection-test-async' -import { sendTerminalInputThroughPane } from './pty-connection-test-dom' -import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('does not surface the zero-dimensions diagnostic for a hidden pane', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const pane = createPane(1) - pane.terminal.cols = 0 - pane.terminal.rows = 0 - const deps = createDeps({ isVisibleRef: { current: false } }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() - }) - - it('still surfaces the zero-dimensions diagnostic for a visible pane', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const pane = createPane(1) - pane.terminal.cols = 0 - pane.terminal.rows = 0 - const deps = createDeps({ isVisibleRef: { current: true } }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith( - pane.id, - expect.stringContaining('Terminal has zero dimensions (0×0)') - ) - }) - - // Why: deleting a worktree kills its PTYs for the filesystem teardown; the - // renderer must not race a doomed respawn into a directory main is deleting - // (main fences it with TerminalRemovalInProgressError and the pane is about to - // unmount). See docs — bad UI was the raw fence error flashing on the tab. - it('skips a fresh spawn while the pane worktree is being deleted', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - deleteStateByWorktreeId: { 'wt-1': { isDeleting: true, phase: 'deleting' } } - } - // Why: a unique tab id keeps this pane's key clear of other tests' pendingSpawnByPaneKey entries so the connect deterministically fresh-spawns. - const deps = createDeps({ tabId: 'tab-removal-skip-spawn' }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).not.toHaveBeenCalled() - expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() - }) - - // The disposed-spawn kill in ipc-pty-connect asks this callback before retiring a PTY; it must - // answer from the live store, or a remounted pane's shell dies under it. - it('lets a disposed spawn survive while the pane surface still exists in the store', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const deps = createDeps({ tabId: 'tab-retain-disposed-spawn' }) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-retain-disposed-spawn', ptyId: null }] } - } - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - const retain = createdTransportOptions[0]?.retainDisposedSpawn - if (typeof retain !== 'function') { - throw new Error('pane transport was built without retainDisposedSpawn') - } - expect(retain()).toBe(true) - mockStoreState = { - ...mockStoreState, - deleteStateByWorktreeId: { 'wt-1': { isDeleting: true, phase: 'deleting' } } - } - expect(retain()).toBe(false) - mockStoreState = { - ...mockStoreState, - deleteStateByWorktreeId: { 'local|wt-1': { isDeleting: true, phase: 'deleting' } } - } - expect(retain()).toBe(false) - mockStoreState = { - ...mockStoreState, - deleteStateByWorktreeId: {}, - tabsByWorktree: { 'wt-1': [] } - } - expect(retain()).toBe(false) - }) - - it('carries the pane placement onto its transport', async () => { - const { connectPanePty } = await import('./pty-connection') - transportFactoryQueue.push(createMockTransport()) - const placement = { kind: 'new-tab' } as const - const deps = createDeps({ tabId: 'tab-placement', placement }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(createdTransportOptions[0]).toMatchObject({ placement }) - }) - - it('fresh-spawns normally when the pane worktree is not being deleted', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - // Why: unique tab id → deterministic fresh spawn (mirrors the skip test's control). - const deps = createDeps({ tabId: 'tab-removal-control-spawn' }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalled() - }) - - // Why: a doomed pane (or a child pane whose parent worktree is being removed, - // which startFreshSpawn's own-worktree skip cannot see) can still race a spawn - // that main fences. reportError must swallow that fence so the tab never flashes - // the raw "Terminal cannot start while the worktree is being removed" banner. - it('swallows a worktree-removal fence error instead of surfacing it', async () => { - const { connectPanePty } = await import('./pty-connection') - const { TERMINAL_REMOVAL_IN_PROGRESS_MESSAGE } = - await import('../../../../shared/worktree/removal-fence-error') - const transport = createMockTransport() - const capturedOnError: { current: ((message: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedOnError.current = callbacks.onError ?? null - return 'pty-1' - }) - transportFactoryQueue.push(transport) - const deps = createDeps({ tabId: 'tab-fence-swallow' }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - // Why: assert the callback was captured before invoking — optional invocation - // would let this test false-pass (not.toHaveBeenCalled trivially true) if the - // transport onError wiring ever broke, exercising no suppression at all. - expect(capturedOnError.current).toBeTypeOf('function') - // Electron wraps the rejected ipcMain error with its own prefix; still swallowed. - capturedOnError.current!( - `Error invoking remote method 'pty:spawn': Error: ${TERMINAL_REMOVAL_IN_PROGRESS_MESSAGE}` - ) - expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() - }) - - it('still surfaces non-fence spawn errors through the pane error sink', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedOnError: { current: ((message: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedOnError.current = callbacks.onError ?? null - return 'pty-1' - }) - transportFactoryQueue.push(transport) - const deps = createDeps({ tabId: 'tab-real-error-surface' }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(capturedOnError.current).toBeTypeOf('function') - capturedOnError.current!('shell exited with code 1') - expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(1, 'shell exited with code 1') - }) - - it('threads the resolved local project runtime into IPC terminal transport options', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - settings: { - ...mockStoreState.settings, - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian', - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } - }, - projects: [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'windows-host' } }] - } - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - - expect(createdTransportOptions[0]?.projectRuntime).toEqual({ - status: 'resolved', - runtime: { - kind: 'windows-host', - hostPlatform: 'win32', - projectId: 'repo1', - reason: 'project-override', - cacheKey: 'repo1:windows-host' - } - }) - }) - - it('keeps an explicit host fallback out of the project runtime', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: null, forceHostRuntime: true }] - }, - settings: { - ...mockStoreState.settings, - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } - } - } - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - - expect(createdTransportOptions[0]?.projectRuntime).toBeUndefined() - }) - - it('spawns a Floating agent on native Windows beside an active WSL project', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createIpcPtyTransport } = await import('./pty-transport') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - activeWorktreeId: 'wt-1', - tabsByWorktree: { - ...mockStoreState.tabsByWorktree, - [FLOATING_TERMINAL_WORKTREE_ID]: [ - { id: 'tab-floating-agent', ptyId: null, launchAgent: 'codex' } - ] - }, - projects: [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }], - settings: { - ...mockStoreState.settings, - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu', - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } - } - } - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ - tabId: 'tab-floating-agent', - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - cwd: 'C:\\Users\\alice', - startup: { launchAgent: 'codex' } - }) as never - ) - await flushAsyncTicks() - - expect(createIpcPtyTransport).toHaveBeenCalledOnce() - expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() - expect(createdTransportOptions[0]).toMatchObject({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - executionHostId: 'local', - launchAgent: 'codex' - }) - expect(createdTransportOptions[0]?.projectRuntime).toBeUndefined() - }) - - it('observes live terminal GitHub PR URLs before agent completion', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-1' - }) - transportFactoryQueue.push(transport) - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - - capturedDataCallback.current?.('Created https://github.com/acme/orca/pull/42\r\n') - - expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( - 'wt-1', - expect.objectContaining({ - url: 'https://github.com/acme/orca/pull/42', - slug: { owner: 'acme', repo: 'orca', host: 'github.com' }, - number: 42 - }), - expect.objectContaining({ tabId: 'tab-1' }) - ) - }) - - it('surfaces an actionable error when a Codex backfill timeout drops to the shell', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-codex-backfill-timeout' - }) - transportFactoryQueue.push(transport) - const deps = createDeps({ - tabId: 'tab-codex-backfill-timeout', - startup: { command: 'codex', launchAgent: 'codex' } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - capturedDataCallback.current?.('timed out waiting for state db back') - capturedDataCallback.current?.('fill\r\n') - - expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith( - 1, - expect.stringContaining('Orca attempts background recovery for managed local and WSL homes') - ) - }) - - it('drops keystrokes while the replay guard is engaged, then forwards once it releases', async () => { - // Regression (cold-restore reattach lockout): a stuck replay guard dropped every keystroke ("can't type after reconnecting"); engaged guard suppresses input, released forwards it. - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const transport = createMockTransport('ssh:ssh-1@@pty-1') - transportFactoryQueue.push(transport) - const deps = createDeps() - - connectPanePty(pane as never, createManager(1, 1) as never, deps as never) - await flushAsyncTicks() - - transport.sendInput.mockClear() - // Engaged (as a stuck reattach would leave it): input must be suppressed. - deps.replayingPanesRef.current.set(pane.id, 3) - sendTerminalInputThroughPane(pane, 'echo hi\r') - expect(transport.sendInput).not.toHaveBeenCalled() - - // Released (via the guard's fallback or parse completion): input flows again. - deps.replayingPanesRef.current.delete(pane.id) - sendTerminalInputThroughPane(pane, 'echo hi\r') - expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r', 'query-reply') - }) - - it('preserves classified user input during replay while suppressing synthetic replies', async () => { - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const userInputListeners = new Set<() => void>() - Object.assign(pane.terminal, { - _core: { - coreService: { - onUserInput: (listener: () => void) => { - userInputListeners.add(listener) - return { dispose: () => userInputListeners.delete(listener) } - } - } - } - }) - const transport = createMockTransport('ssh:ssh-1@@pty-1') - transportFactoryQueue.push(transport) - const deps = createDeps() - const deferred: (() => void)[] = [] - Object.assign(deps, { - deferPtyInput: (_paneId: number, data: string, forward: (data: string) => void) => { - deferred.push(() => forward(data)) - } - }) - connectPanePty(pane as never, createManager(1, 1) as never, deps as never) - await flushAsyncTicks() - transport.sendInput.mockClear() - deps.replayingPanesRef.current.set(pane.id, 1) - for (const listener of userInputListeners) { - listener() - } - sendTerminalInputThroughPane(pane, 'input_under_flood\r') - sendTerminalInputThroughPane(pane, '\x1b[?1;2c') - // A click on replayed scrollback that still has mouse tracking armed is user input to xterm, but must not reach the shell. - for (const listener of userInputListeners) { - listener() - } - sendTerminalInputThroughPane(pane, '\x1b[<0;12;4M') - for (const forward of deferred.splice(0)) { - forward() - } - expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r', 'driving') - - // A wheel over a replayed alt-screen frame becomes cursor keys; the fresh shell must not recall history from them. - pane.terminal.buffer.active.type = 'alternate' - for (const listener of userInputListeners) { - listener() - } - sendTerminalInputThroughPane(pane, '\x1b[B') - for (const forward of deferred.splice(0)) { - forward() - } - expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r', 'driving') - - // The same bytes on the normal buffer can only be a keyboard arrow, which survives replay. - pane.terminal.buffer.active.type = 'normal' - for (const listener of userInputListeners) { - listener() - } - sendTerminalInputThroughPane(pane, '\x1b[B') - for (const forward of deferred.splice(0)) { - forward() - } - expect(transport.sendInput).toHaveBeenCalledTimes(2) - expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[B', 'driving') - - // Once the guard releases, the same mouse report is ordinary input again. - deps.replayingPanesRef.current.delete(pane.id) - for (const listener of userInputListeners) { - listener() - } - sendTerminalInputThroughPane(pane, '\x1b[<0;12;4M') - for (const forward of deferred.splice(0)) { - forward() - } - expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[<0;12;4M', 'driving') - }) - - it('settles a queued startup only after the pane binds its spawned PTY', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-resume') - transportFactoryQueue.push(transport) - const onStartupBound = vi.fn() - const startup = { - command: "codex 'resume' 'codex-session-1'", - resumeProviderSession: { key: 'session_id', id: 'codex-session-1' } as const - } - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ startup, onStartupBound }) as never - ) - - expect(onStartupBound).not.toHaveBeenCalled() - expect(createdTransportOptions[0]).toMatchObject(startup) - - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - onPtySpawn?.('pty-resume') - onPtySpawn?.('pty-resume') - - expect(onStartupBound).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.spec.ts new file mode 100644 index 00000000000..01f36f8b799 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.spec.ts @@ -0,0 +1,696 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { sendTerminalInputThroughPane } from './pty-connection-test-dom' +import { + LEAF_2, + leafIdForPane, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { ConnectCallbacks, MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionHibernationWakeTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('disarms input modes and resumes a hibernated agent session on visibility reveal', async () => { + // Regression: hibernation suppresses its kill's PTY exit while hidden; before the wake fix onExit permanently latched handledExitPtyId, leaving a frozen inert ghost pane on reveal. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + // Hibernation only targets hidden panes, so the exit lands while not visible and the wake must wait for the reveal. + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-1' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + mockStoreState.suppressedPtyExitIds['tab-pty'] = true + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + noteVisibilityResume: () => void + dispose: () => void + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + // The deferred connect attached this transport to the persisted tab PTY, so the kill's exit must carry that id for the wake guard's same-pty check. + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + const connectCallsBeforeExit = transport.connect.mock.calls.length + onPtyExit?.('tab-pty') + await flushAsyncTicks() + + // The frozen frame's input-eating modes are disarmed at hibernation-exit (mouse tracking / bracketed paste would otherwise swallow clicks). + const writesAfterExit = pane.terminal.write.mock.calls.flat().join('') + expect(writesAfterExit).toContain('\x1b[?1003l') + expect(writesAfterExit).toContain('\x1b[?2004l') + // A hidden pane must not respawn on exit — the wake waits for the reveal. + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + + binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) + const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as + | { command?: string } + | undefined + expect(resumeConnectOptions?.command).toContain('--resume') + expect(resumeConnectOptions?.command).toContain('sess-hibernated-1') + + // The wake is one-shot: a second reveal must not spawn again. + const connectCallsAfterWake = transport.connect.mock.calls.length + binding.noteVisibilityResume() + await flushAsyncTicks() + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) + }) + + // Why: pins #16308's incidental effect. Hibernation writes a worktree-sleep note, yet any + // suppressed exit over a finished turn's live idle anchor now arms a resume on reveal. + it('arms an in-place resume from a finished turn idle anchor on a suppressed exit', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-idle-anchor' }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'live' + } + mockStoreState.suppressedPtyExitIds['tab-pty'] = true + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. + const args = [pane, createManager(1), deps] as unknown as Parameters + const binding = connectPanePty(...args) + await flushAsyncTicks() + const onPtyExit = createdTransportOptions[0]?.onPtyExit + expect(onPtyExit).toBeTypeOf('function') + const connectCallsBeforeExit = transport.connect.mock.calls.length + if (typeof onPtyExit === 'function') { + onPtyExit('tab-pty') + } + await flushAsyncTicks() + + binding.noteVisibilityResume() + await flushAsyncTicks() + + const resumeConnectOptions: { command?: string } | undefined = + transport.connect.mock.calls.at(-1)?.[0] + expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) + expect(resumeConnectOptions?.command).toContain('sess-idle-anchor') + }) + + it('resumes a hibernated agent from a navigation-free wake without a visibility reveal', async () => { + // Mobile wake fanout drives wakeHibernatedAgentIfArmed on a still-hidden pane (no isVisible flip): the armed --resume must fire exactly once even if delivered twice (INV-1). + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-bg' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + mockStoreState.suppressedPtyExitIds['tab-pty'] = true + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null + dispose: () => void + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + const connectCallsBeforeExit = transport.connect.mock.calls.length + onPtyExit?.('tab-pty') + await flushAsyncTicks() + // Still hidden: no reveal happened, so nothing respawned on exit. + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + + // The wake reports the claim it consumed so the dispatcher's generic resume never launches the same provider session into a second tab. + const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-bg' + expect(binding.wakeHibernatedAgentIfArmed(new Set([claimKey]))).toBeNull() + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + const claimedProviderSessions = new Set() + expect(binding.wakeHibernatedAgentIfArmed(claimedProviderSessions)).toBe(claimKey) + expect(claimedProviderSessions).toEqual(new Set([claimKey])) + await flushAsyncTicks() + + expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) + const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as + | { command?: string } + | undefined + expect(resumeConnectOptions?.command).toContain('--resume') + expect(resumeConnectOptions?.command).toContain('sess-hibernated-bg') + + // A second navigation-free wake must not spawn again (one-pane/one-PTY). + const connectCallsAfterWake = transport.connect.mock.calls.length + binding.wakeHibernatedAgentIfArmed() + await flushAsyncTicks() + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) + }) + + it('names the replaced PTY on the restart spawn only, never on a later wake of the same pane', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-restarted') + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId: 'tab-restart-wake', + startup: { command: 'claude', launchAgent: 'claude' }, + replacesPtyId: 'pty-replaced', + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const pane = createPane(2) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. + const args = [pane, createManager(1), deps] as unknown as Parameters + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pane binding exposes the wake hook this test drives. + const binding = connectPanePty(...args) as unknown as { + wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null + dispose: () => void + } + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + const restartConnect: { claimReplacedPtyId?: () => string | null } | undefined = + transport.connect.mock.calls[0]?.[0] + // The IPC transport takes the id as it sends the spawn; this mock transport does it here. + expect(restartConnect?.claimReplacedPtyId?.()).toBe('pty-replaced') + // Why: transport options outlive the first spawn, so the field must not ride them. + expect(createdTransportOptions[0]).not.toHaveProperty('replacesPtyId') + + const paneKey = `tab-restart-wake:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-restart-wake', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-restart-wake' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + mockStoreState.suppressedPtyExitIds['pty-restarted'] = true + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + onPtyExit?.('pty-restarted') + await flushAsyncTicks() + expect(binding.wakeHibernatedAgentIfArmed(new Set())).not.toBeNull() + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(2) + expect(transport.connect.mock.calls[1]?.[0]).not.toHaveProperty('claimReplacedPtyId') + binding.dispose() + await flushAsyncTicks() + expect(window.api.pty.kill).not.toHaveBeenCalledWith('pty-replaced') + }) + + it('keeps an established split pane mounted when main labels its exit as a restart replacement', async () => { + const { connectPanePty } = await import('./pty-connection') + const { deliverPtyExitToHandlers } = await import('./pty-exit-delivery') + let onData: ((data: string) => void) | undefined + const transport = createMockTransport('pty-pane-2') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + onData = callbacks.onData + return 'pty-pane-2' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. + const args = [createPane(2), manager, deps] as unknown as Parameters + connectPanePty(...args) + const onPtyExit = createdTransportOptions[0]?.onPtyExit + expect(onPtyExit).toBeTypeOf('function') + await flushAsyncTicks() + onData?.('codex prompt') + + deliverPtyExitToHandlers({ + ptyId: 'pty-pane-2', + code: 0, + replacedByRestart: true, + primary: (code) => { + if (typeof onPtyExit === 'function') { + onPtyExit('pty-pane-2', code) + } + }, + sidecars: [] + }) + + expect(manager.closePane).not.toHaveBeenCalled() + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + }) + + it('stops the replaced PTY itself when the pane is disposed before a spawn carried the stop', async () => { + const { connectPanePty } = await import('./pty-connection') + transportFactoryQueue.push(createMockTransport('pty-restarted')) + const deps = createDeps({ + tabId: 'tab-restart-closed', + startup: { command: 'codex', launchAgent: 'codex' }, + replacesPtyId: 'pty-replaced' + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. + const args = [createPane(2), createManager(1), deps] as unknown as Parameters< + typeof connectPanePty + > + // Closing the tab (or parking it) disposes the pane before its deferred connect runs. + connectPanePty(...args).dispose() + await flushAsyncTicks() + + expect(window.api.pty.kill).toHaveBeenCalledWith('pty-replaced') + }) + + it('latches a navigation-free wake that lands before the hibernation kill arms the pane', async () => { + // Race (#7906): the edge-triggered wake can land after the sleeping record but before the kill sets hibernatedWakePtyId; without a latch it'd be dropped, leaving a frozen terminal. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-race' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + mockStoreState.suppressedPtyExitIds['tab-pty'] = true + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null + dispose: () => void + } + await flushAsyncTicks() + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + + // Wake arrives mid-kill: nothing is armed yet, but the pane must claim the session (suppressing the generic resume) and latch the request. + const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-race' + expect(binding.wakeHibernatedAgentIfArmed(new Set([claimKey]))).toBeNull() + const claimedProviderSessions = new Set() + expect(binding.wakeHibernatedAgentIfArmed(claimedProviderSessions)).toBe(claimKey) + expect(claimedProviderSessions).toEqual(new Set([claimKey])) + const connectCallsBeforeExit = transport.connect.mock.calls.length + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + onPtyExit?.('tab-pty') + await flushAsyncTicks() + + // Arming consumed the latched wake — the --resume spawned with no reveal and no second wake event. + expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) + const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as + | { command?: string } + | undefined + expect(resumeConnectOptions?.command).toContain('--resume') + expect(resumeConnectOptions?.command).toContain('sess-hibernated-race') + }) + + it('keeps an in-place provider claim until the replacement PTY spawn settles', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-inflight' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + const binding = connectPanePty( + createPane(2) as never, + createManager(1) as never, + deps as never + ) as unknown as { + wakeHibernatedAgentIfArmed: () => string | null + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + onPtyExit?.('tab-pty') + await flushAsyncTicks() + const deferredSpawn = createDeferred() + transport.connect.mockImplementationOnce(() => deferredSpawn.promise) + + const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-inflight' + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) + const connectCallsAfterFirstWake = transport.connect.mock.calls.length + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterFirstWake) + + deferredSpawn.resolve('pty-resumed') + await flushAsyncTicks() + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) + }) + + it('re-arms the exact hibernation target after a replacement spawn fails', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-retry' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + const binding = connectPanePty( + createPane(2) as never, + createManager(1) as never, + createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) as never + ) as unknown as { wakeHibernatedAgentIfArmed: () => string | null } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + onPtyExit?.('tab-pty') + await flushAsyncTicks() + transport.connect.mockRejectedValueOnce(new Error('transient spawn failure')) + + const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-retry' + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) + await flushAsyncTicks(20) + const connectCallsAfterFailure = transport.connect.mock.calls.length + + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterFailure + 1) + }) + + it('does not latch a stale sleeping record beside an unsuppressed live PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-stale' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + const binding = connectPanePty( + createPane(2) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) as unknown as { + wakeHibernatedAgentIfArmed: () => string | null + } + await flushAsyncTicks() + + expect(binding.wakeHibernatedAgentIfArmed()).toBeNull() + }) + + it('auto-resumes a hibernated pane when its kill lands after the pane is already revealed', async () => { + // Race: reveal's noteVisibilityResume runs before onExit arms the wake, so the arm-time foreground check must resume the pane instead of stranding a disarmed-but-dead frame. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: true } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-2' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + noteVisibilityResume: () => void + dispose: () => void + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + // The deferred connect attached this transport to the persisted tab PTY, so the kill's exit must carry that id for the wake guard's same-pty check. + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + const connectCallsBeforeExit = transport.connect.mock.calls.length + onPtyExit?.('tab-pty') + await flushAsyncTicks() + + // No second reveal was needed: the foreground pane resumed its recorded session directly from the arm-time wake. + expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) + const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as + | { command?: string } + | undefined + expect(resumeConnectOptions?.command).toContain('--resume') + expect(resumeConnectOptions?.command).toContain('sess-hibernated-2') + + // Still one-shot: a later reveal must not spawn again. + const connectCallsAfterWake = transport.connect.mock.calls.length + binding.noteVisibilityResume() + await flushAsyncTicks() + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) + }) + + it('invalidates the hibernation wake when another flow rebinds the pane before reveal', async () => { + // Why: intentional restarts share hibernation's exit suppression; a rebind while hidden owns the pane, so the armed wake must be discarded on reveal — permanently, not revived by a later death. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-3' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + noteVisibilityResume: () => void + dispose: () => void + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + const connectCallsBeforeExit = transport.connect.mock.calls.length + onPtyExit?.('tab-pty') + await flushAsyncTicks() + + // Another flow rebinds the pane to a fresh PTY while it is still hidden. + transport.getPtyId.mockReturnValue('pty-restarted') + binding.noteVisibilityResume() + await flushAsyncTicks() + // The rebound pane keeps its own session: no wake-driven resume spawn. + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + + // The rebound PTY later dies without a new sleeping record; the stale wake must not fire on the next reveal. + transport.getPtyId.mockReturnValue(null) + binding.noteVisibilityResume() + await flushAsyncTicks() + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + }) + + it('records hibernation activity from the core user-input signal, not synthetic onData replies', async () => { + // Regression: xterm auto-replies share the onData stream with typing; recording them as input made the planner see "input after done" and never hibernate. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + const pane = createPane(2) + const userInputListeners = new Set<() => void>() + ;(pane.terminal as unknown as { _core: unknown })._core = { + coreService: { + onUserInput: vi.fn((listener: () => void) => { + userInputListeners.add(listener) + return { dispose: () => userInputListeners.delete(listener) } + }) + } + } + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + dispose: () => void + } + await flushAsyncTicks() + expect(userInputListeners.size).toBeGreaterThan(0) + ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() + + // A focus-out report forwarded to the PTY must not count as activity. + sendTerminalInputThroughPane(pane, '\x1b[O') + expect(mockStoreState.recordTerminalInput).not.toHaveBeenCalled() + // The reply still reaches the shell; only the activity recording is gated. + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O', 'query-reply') + + // Real user input fires the core signal and records activity. + for (const listener of userInputListeners) { + listener() + } + expect(mockStoreState.recordTerminalInput).toHaveBeenCalledTimes(1) + + binding.dispose() + expect(userInputListeners.size).toBe(0) + }) + + it('falls back to onData hibernation recording when the core user-input signal is unavailable', async () => { + // If an xterm upgrade removes the internal signal, activity recording must degrade to the historical onData behavior — never to no tracking at all. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + const pane = createPane(2) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() + + sendTerminalInputThroughPane(pane, 'x') + expect(mockStoreState.recordTerminalInput).toHaveBeenCalledTimes(1) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts deleted file mode 100644 index 81e57fc6ab5..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts +++ /dev/null @@ -1,769 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' -import { sendTerminalInputThroughPane } from './pty-connection-test-dom' -import { - LEAF_2, - leafIdForPane, - createMockTransport, - createPane, - createManager -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import type { ConnectCallbacks, MockTransport } from './pty-connection-test-pane-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('disarms input modes and resumes a hibernated agent session on visibility reveal', async () => { - // Regression: hibernation suppresses its kill's PTY exit while hidden; before the wake fix onExit permanently latched handledExitPtyId, leaving a frozen inert ghost pane on reveal. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(1) - // Hibernation only targets hidden panes, so the exit lands while not visible and the wake must wait for the reveal. - const deps = createDeps({ - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef: { current: false } - }) - const pane = createPane(2) - const paneKey = `tab-1:${leafIdForPane(2)}` - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'sess-hibernated-1' }, - prompt: 'test prompt', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' - } - mockStoreState.suppressedPtyExitIds['tab-pty'] = true - - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - noteVisibilityResume: () => void - dispose: () => void - } - await flushAsyncTicks() - - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - expect(onPtyExit).toBeTypeOf('function') - // The deferred connect attached this transport to the persisted tab PTY, so the kill's exit must carry that id for the wake guard's same-pty check. - expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') - const connectCallsBeforeExit = transport.connect.mock.calls.length - onPtyExit?.('tab-pty') - await flushAsyncTicks() - - // The frozen frame's input-eating modes are disarmed at hibernation-exit (mouse tracking / bracketed paste would otherwise swallow clicks). - const writesAfterExit = pane.terminal.write.mock.calls.flat().join('') - expect(writesAfterExit).toContain('\x1b[?1003l') - expect(writesAfterExit).toContain('\x1b[?2004l') - // A hidden pane must not respawn on exit — the wake waits for the reveal. - expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) - - binding.noteVisibilityResume() - await flushAsyncTicks() - - expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) - const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as - | { command?: string } - | undefined - expect(resumeConnectOptions?.command).toContain('--resume') - expect(resumeConnectOptions?.command).toContain('sess-hibernated-1') - - // The wake is one-shot: a second reveal must not spawn again. - const connectCallsAfterWake = transport.connect.mock.calls.length - binding.noteVisibilityResume() - await flushAsyncTicks() - expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) - }) - - // Why: pins #16308's incidental effect. Hibernation writes a worktree-sleep note, yet any - // suppressed exit over a finished turn's live idle anchor now arms a resume on reveal. - it('arms an in-place resume from a finished turn idle anchor on a suppressed exit', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const deps = createDeps({ - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef: { current: false } - }) - const pane = createPane(2) - const paneKey = `tab-1:${leafIdForPane(2)}` - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'sess-idle-anchor' }, - prompt: '', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'live' - } - mockStoreState.suppressedPtyExitIds['tab-pty'] = true - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. - const args = [pane, createManager(1), deps] as unknown as Parameters - const binding = connectPanePty(...args) - await flushAsyncTicks() - const onPtyExit = createdTransportOptions[0]?.onPtyExit - expect(onPtyExit).toBeTypeOf('function') - const connectCallsBeforeExit = transport.connect.mock.calls.length - if (typeof onPtyExit === 'function') { - onPtyExit('tab-pty') - } - await flushAsyncTicks() - - binding.noteVisibilityResume() - await flushAsyncTicks() - - const resumeConnectOptions: { command?: string } | undefined = - transport.connect.mock.calls.at(-1)?.[0] - expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) - expect(resumeConnectOptions?.command).toContain('sess-idle-anchor') - }) - - it('resumes a hibernated agent from a navigation-free wake without a visibility reveal', async () => { - // Mobile wake fanout drives wakeHibernatedAgentIfArmed on a still-hidden pane (no isVisible flip): the armed --resume must fire exactly once even if delivered twice (INV-1). - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps({ - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef: { current: false } - }) - const pane = createPane(2) - const paneKey = `tab-1:${leafIdForPane(2)}` - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'sess-hibernated-bg' }, - prompt: 'test prompt', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' - } - mockStoreState.suppressedPtyExitIds['tab-pty'] = true - - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null - dispose: () => void - } - await flushAsyncTicks() - - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') - const connectCallsBeforeExit = transport.connect.mock.calls.length - onPtyExit?.('tab-pty') - await flushAsyncTicks() - // Still hidden: no reveal happened, so nothing respawned on exit. - expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) - - // The wake reports the claim it consumed so the dispatcher's generic resume never launches the same provider session into a second tab. - const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-bg' - expect(binding.wakeHibernatedAgentIfArmed(new Set([claimKey]))).toBeNull() - expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) - const claimedProviderSessions = new Set() - expect(binding.wakeHibernatedAgentIfArmed(claimedProviderSessions)).toBe(claimKey) - expect(claimedProviderSessions).toEqual(new Set([claimKey])) - await flushAsyncTicks() - - expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) - const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as - | { command?: string } - | undefined - expect(resumeConnectOptions?.command).toContain('--resume') - expect(resumeConnectOptions?.command).toContain('sess-hibernated-bg') - - // A second navigation-free wake must not spawn again (one-pane/one-PTY). - const connectCallsAfterWake = transport.connect.mock.calls.length - binding.wakeHibernatedAgentIfArmed() - await flushAsyncTicks() - expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) - }) - - it('names the replaced PTY on the restart spawn only, never on a later wake of the same pane', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-restarted') - transportFactoryQueue.push(transport) - const deps = createDeps({ - tabId: 'tab-restart-wake', - startup: { command: 'claude', launchAgent: 'claude' }, - replacesPtyId: 'pty-replaced', - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef: { current: false } - }) - const pane = createPane(2) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. - const args = [pane, createManager(1), deps] as unknown as Parameters - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pane binding exposes the wake hook this test drives. - const binding = connectPanePty(...args) as unknown as { - wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null - dispose: () => void - } - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - const restartConnect: { claimReplacedPtyId?: () => string | null } | undefined = - transport.connect.mock.calls[0]?.[0] - // The IPC transport takes the id as it sends the spawn; this mock transport does it here. - expect(restartConnect?.claimReplacedPtyId?.()).toBe('pty-replaced') - // Why: transport options outlive the first spawn, so the field must not ride them. - expect(createdTransportOptions[0]).not.toHaveProperty('replacesPtyId') - - const paneKey = `tab-restart-wake:${leafIdForPane(2)}` - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - tabId: 'tab-restart-wake', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'sess-restart-wake' }, - prompt: 'test prompt', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' - } - mockStoreState.suppressedPtyExitIds['pty-restarted'] = true - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - onPtyExit?.('pty-restarted') - await flushAsyncTicks() - expect(binding.wakeHibernatedAgentIfArmed(new Set())).not.toBeNull() - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(2) - expect(transport.connect.mock.calls[1]?.[0]).not.toHaveProperty('claimReplacedPtyId') - binding.dispose() - await flushAsyncTicks() - expect(window.api.pty.kill).not.toHaveBeenCalledWith('pty-replaced') - }) - - it('keeps an established split pane mounted when main labels its exit as a restart replacement', async () => { - const { connectPanePty } = await import('./pty-connection') - const { deliverPtyExitToHandlers } = await import('./pty-exit-delivery') - let onData: ((data: string) => void) | undefined - const transport = createMockTransport('pty-pane-2') - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - onData = callbacks.onData - return 'pty-pane-2' - }) - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. - const args = [createPane(2), manager, deps] as unknown as Parameters - connectPanePty(...args) - const onPtyExit = createdTransportOptions[0]?.onPtyExit - expect(onPtyExit).toBeTypeOf('function') - await flushAsyncTicks() - onData?.('codex prompt') - - deliverPtyExitToHandlers({ - ptyId: 'pty-pane-2', - code: 0, - replacedByRestart: true, - primary: (code) => { - if (typeof onPtyExit === 'function') { - onPtyExit('pty-pane-2', code) - } - }, - sidecars: [] - }) - - expect(manager.closePane).not.toHaveBeenCalled() - expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() - }) - - it('stops the replaced PTY itself when the pane is disposed before a spawn carried the stop', async () => { - const { connectPanePty } = await import('./pty-connection') - transportFactoryQueue.push(createMockTransport('pty-restarted')) - const deps = createDeps({ - tabId: 'tab-restart-closed', - startup: { command: 'codex', launchAgent: 'codex' }, - replacesPtyId: 'pty-replaced' - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. - const args = [createPane(2), createManager(1), deps] as unknown as Parameters< - typeof connectPanePty - > - // Closing the tab (or parking it) disposes the pane before its deferred connect runs. - connectPanePty(...args).dispose() - await flushAsyncTicks() - - expect(window.api.pty.kill).toHaveBeenCalledWith('pty-replaced') - }) - - it('latches a navigation-free wake that lands before the hibernation kill arms the pane', async () => { - // Race (#7906): the edge-triggered wake can land after the sleeping record but before the kill sets hibernatedWakePtyId; without a latch it'd be dropped, leaving a frozen terminal. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps({ - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef: { current: false } - }) - const pane = createPane(2) - const paneKey = `tab-1:${leafIdForPane(2)}` - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'sess-hibernated-race' }, - prompt: 'test prompt', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' - } - mockStoreState.suppressedPtyExitIds['tab-pty'] = true - - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null - dispose: () => void - } - await flushAsyncTicks() - expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') - - // Wake arrives mid-kill: nothing is armed yet, but the pane must claim the session (suppressing the generic resume) and latch the request. - const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-race' - expect(binding.wakeHibernatedAgentIfArmed(new Set([claimKey]))).toBeNull() - const claimedProviderSessions = new Set() - expect(binding.wakeHibernatedAgentIfArmed(claimedProviderSessions)).toBe(claimKey) - expect(claimedProviderSessions).toEqual(new Set([claimKey])) - const connectCallsBeforeExit = transport.connect.mock.calls.length - - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - onPtyExit?.('tab-pty') - await flushAsyncTicks() - - // Arming consumed the latched wake — the --resume spawned with no reveal and no second wake event. - expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) - const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as - | { command?: string } - | undefined - expect(resumeConnectOptions?.command).toContain('--resume') - expect(resumeConnectOptions?.command).toContain('sess-hibernated-race') - }) - - it('keeps an in-place provider claim until the replacement PTY spawn settles', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const deps = createDeps({ - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef: { current: false } - }) - const paneKey = `tab-1:${leafIdForPane(2)}` - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'sess-hibernated-inflight' }, - prompt: 'test prompt', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' - } - const binding = connectPanePty( - createPane(2) as never, - createManager(1) as never, - deps as never - ) as unknown as { - wakeHibernatedAgentIfArmed: () => string | null - } - await flushAsyncTicks() - - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - onPtyExit?.('tab-pty') - await flushAsyncTicks() - const deferredSpawn = createDeferred() - transport.connect.mockImplementationOnce(() => deferredSpawn.promise) - - const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-inflight' - expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) - const connectCallsAfterFirstWake = transport.connect.mock.calls.length - expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) - expect(transport.connect.mock.calls.length).toBe(connectCallsAfterFirstWake) - - deferredSpawn.resolve('pty-resumed') - await flushAsyncTicks() - expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) - }) - - it('re-arms the exact hibernation target after a replacement spawn fails', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const paneKey = `tab-1:${leafIdForPane(2)}` - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'sess-hibernated-retry' }, - prompt: 'test prompt', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' - } - const binding = connectPanePty( - createPane(2) as never, - createManager(1) as never, - createDeps({ - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef: { current: false } - }) as never - ) as unknown as { wakeHibernatedAgentIfArmed: () => string | null } - await flushAsyncTicks() - - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - onPtyExit?.('tab-pty') - await flushAsyncTicks() - transport.connect.mockRejectedValueOnce(new Error('transient spawn failure')) - - const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-retry' - expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) - await flushAsyncTicks(20) - const connectCallsAfterFailure = transport.connect.mock.calls.length - - expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) - expect(transport.connect.mock.calls.length).toBe(connectCallsAfterFailure + 1) - }) - - it('does not latch a stale sleeping record beside an unsuppressed live PTY', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const paneKey = `tab-1:${leafIdForPane(2)}` - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'sess-stale' }, - prompt: 'test prompt', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' - } - const binding = connectPanePty( - createPane(2) as never, - createManager(1) as never, - createDeps({ isVisibleRef: { current: false } }) as never - ) as unknown as { - wakeHibernatedAgentIfArmed: () => string | null - } - await flushAsyncTicks() - - expect(binding.wakeHibernatedAgentIfArmed()).toBeNull() - }) - - it('auto-resumes a hibernated pane when its kill lands after the pane is already revealed', async () => { - // Race: reveal's noteVisibilityResume runs before onExit arms the wake, so the arm-time foreground check must resume the pane instead of stranding a disarmed-but-dead frame. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps({ - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef: { current: true } - }) - const pane = createPane(2) - const paneKey = `tab-1:${leafIdForPane(2)}` - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'sess-hibernated-2' }, - prompt: 'test prompt', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' - } - - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - noteVisibilityResume: () => void - dispose: () => void - } - await flushAsyncTicks() - - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - expect(onPtyExit).toBeTypeOf('function') - // The deferred connect attached this transport to the persisted tab PTY, so the kill's exit must carry that id for the wake guard's same-pty check. - expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') - const connectCallsBeforeExit = transport.connect.mock.calls.length - onPtyExit?.('tab-pty') - await flushAsyncTicks() - - // No second reveal was needed: the foreground pane resumed its recorded session directly from the arm-time wake. - expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) - const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as - | { command?: string } - | undefined - expect(resumeConnectOptions?.command).toContain('--resume') - expect(resumeConnectOptions?.command).toContain('sess-hibernated-2') - - // Still one-shot: a later reveal must not spawn again. - const connectCallsAfterWake = transport.connect.mock.calls.length - binding.noteVisibilityResume() - await flushAsyncTicks() - expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) - }) - - it('invalidates the hibernation wake when another flow rebinds the pane before reveal', async () => { - // Why: intentional restarts share hibernation's exit suppression; a rebind while hidden owns the pane, so the armed wake must be discarded on reveal — permanently, not revived by a later death. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps({ - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef: { current: false } - }) - const pane = createPane(2) - const paneKey = `tab-1:${leafIdForPane(2)}` - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'sess-hibernated-3' }, - prompt: 'test prompt', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' - } - - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - noteVisibilityResume: () => void - dispose: () => void - } - await flushAsyncTicks() - - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - expect(onPtyExit).toBeTypeOf('function') - expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') - const connectCallsBeforeExit = transport.connect.mock.calls.length - onPtyExit?.('tab-pty') - await flushAsyncTicks() - - // Another flow rebinds the pane to a fresh PTY while it is still hidden. - transport.getPtyId.mockReturnValue('pty-restarted') - binding.noteVisibilityResume() - await flushAsyncTicks() - // The rebound pane keeps its own session: no wake-driven resume spawn. - expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) - - // The rebound PTY later dies without a new sleeping record; the stale wake must not fire on the next reveal. - transport.getPtyId.mockReturnValue(null) - binding.noteVisibilityResume() - await flushAsyncTicks() - expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) - }) - - it('records hibernation activity from the core user-input signal, not synthetic onData replies', async () => { - // Regression: xterm auto-replies share the onData stream with typing; recording them as input made the planner see "input after done" and never hibernate. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps() - const pane = createPane(2) - const userInputListeners = new Set<() => void>() - ;(pane.terminal as unknown as { _core: unknown })._core = { - coreService: { - onUserInput: vi.fn((listener: () => void) => { - userInputListeners.add(listener) - return { dispose: () => userInputListeners.delete(listener) } - }) - } - } - - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - dispose: () => void - } - await flushAsyncTicks() - expect(userInputListeners.size).toBeGreaterThan(0) - ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() - - // A focus-out report forwarded to the PTY must not count as activity. - sendTerminalInputThroughPane(pane, '\x1b[O') - expect(mockStoreState.recordTerminalInput).not.toHaveBeenCalled() - // The reply still reaches the shell; only the activity recording is gated. - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O', 'query-reply') - - // Real user input fires the core signal and records activity. - for (const listener of userInputListeners) { - listener() - } - expect(mockStoreState.recordTerminalInput).toHaveBeenCalledTimes(1) - - binding.dispose() - expect(userInputListeners.size).toBe(0) - }) - - it('falls back to onData hibernation recording when the core user-input signal is unavailable', async () => { - // If an xterm upgrade removes the internal signal, activity recording must degrade to the historical onData behavior — never to no tracking at all. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps() - const pane = createPane(2) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() - - sendTerminalInputThroughPane(pane, 'x') - expect(mockStoreState.recordTerminalInput).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.spec.ts new file mode 100644 index 00000000000..62985db0f8e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.spec.ts @@ -0,0 +1,739 @@ +import { bindPtyConnectionTestMocks, ptyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + captureCallbackTerminalWrites, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionHiddenAtlasRecoveryTests(): void { + const { resetAndRefreshAllTerminalWebglAtlases, scheduleTerminalWebglAtlasRecovery } = + ptyConnectionTestMocks + + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + function expectNoGlobalAtlasRecovery(): void { + // Why: the removed output path requested recovery 200ms before its global reset ran. + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() + } + + // Why: connectPanePty takes the real pane/manager/deps types and every test in this file + // passes structural fakes. Funnelling the widening through one helper keeps the casts in + // a single reviewable place instead of three per test. + function connectFakePane( + connect: (pane: never, manager: never, deps: never) => { dispose: () => void }, + pane: unknown, + manager: unknown, + deps: Parameters[0] + ): { dispose: () => void } { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the three values are this suite's structural fakes for pane/manager/deps; connectPanePty only reads the fields they define. + return connect(pane as never, manager as never, createDeps(deps) as never) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('keeps hidden synchronized output off the global atlas recovery path', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + const startChunk = '\x1b[?2026h' + const plainRowChunk = '| hidden Claude row |\r\n' + const endChunk = '\x1b[?2026l' + + capturedDataCallback.current?.(startChunk) + capturedDataCallback.current?.(plainRowChunk) + capturedDataCallback.current?.(endChunk) + + expect(writes).toEqual([]) + vi.advanceTimersByTime(50) + + expect(writes).toEqual([`${startChunk}${plainRowChunk}${endChunk}`]) + + parseCallbacks[0]?.() + expectNoGlobalAtlasRecovery() + } finally { + vi.useRealTimers() + } + }) + + it('keeps split hidden synchronized output off the global atlas recovery path', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[?202') + capturedDataCallback.current?.('6hbody row\r\n') + capturedDataCallback.current?.('tail\x1b[?20') + capturedDataCallback.current?.('26l') + + vi.advanceTimersByTime(50) + + expect(writes.join('')).toBe('\x1b[?2026hbody row\r\ntail\x1b[?2026l') + + parseCallbacks[0]?.() + expectNoGlobalAtlasRecovery() + } finally { + vi.useRealTimers() + } + }) + + it('does not schedule hidden atlas recovery for ordinary rich text or metadata output', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('plain hidden emoji 😀 and CJK 没改什么\r\n') + capturedDataCallback.current?.('\x1b[48;2;52;52;52mcolored shell text\x1b[0m\r\n') + capturedDataCallback.current?.('\x1b]0;hidden title\x07\x1b]133;A\x07') + + vi.advanceTimersByTime(50) + for (const callback of parseCallbacks) { + callback() + } + expectNoGlobalAtlasRecovery() + } finally { + vi.useRealTimers() + } + }) + + it('keeps hidden TUI redraw output off the global atlas recovery path', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[2J\x1b[Hredrawn hidden table\x1b[K') + + vi.advanceTimersByTime(50) + + parseCallbacks[0]?.() + expectNoGlobalAtlasRecovery() + } finally { + vi.useRealTimers() + } + }) + + it('keeps hidden rewrite frames on the pane-local output path', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('prompt rewrite\r') + vi.advanceTimersByTime(50) + expect(writes).toEqual(['prompt rewrite\r']) + parseCallbacks.shift()?.() + + capturedDataCallback.current?.('\x1b[?2026hredraw frame\x1b[?2026l') + vi.advanceTimersByTime(50) + expect(writes).toEqual(['prompt rewrite\r', '\x1b[?2026hredraw frame\x1b[?2026l']) + parseCallbacks.shift()?.() + + capturedDataCallback.current?.('plain after frame') + vi.advanceTimersByTime(50) + parseCallbacks.shift()?.() + expectNoGlobalAtlasRecovery() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a background-origin alternate-screen chunk in the stream, pane-local', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { background?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const isVisibleRef = { current: false } + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[?2026h') + vi.advanceTimersByTime(50) + parseCallbacks.shift()?.() + writes.length = 0 + + isVisibleRef.current = true + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + capturedDataCallback.current?.('\x1b[?2026l', { background: true }) + expect(writes).toEqual([]) + + isVisibleRef.current = false + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' + capturedDataCallback.current?.('plain after close\r\n') + vi.advanceTimersByTime(50) + parseCallbacks.shift()?.() + + // Dropping the close would strand xterm inside synchronized output. + expect(writes).toEqual(['\x1b[?2026lplain after close\r\n']) + expectNoGlobalAtlasRecovery() + } finally { + vi.useRealTimers() + } + }) + + it('queues visible split-pane PTY bytes when the pane is not active', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isActiveRef: { current: false }, + isVisibleRef: { current: true } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() + const redraw = '\x1b[2J\x1b[Hvisible split output\r\n' + capturedDataCallback.current?.(redraw) + + expect(pane.terminal.write).not.toHaveBeenCalledWith(redraw, expect.any(Function)) + vi.advanceTimersByTime(0) + expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) + }) + + it('queues visible ANSI redraws when only another split pane is active', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = { + ...createManager(2), + getActivePane: vi.fn(() => ({ id: 2 })) + } + const deps = createDeps({ + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() + const redraw = '\x1b[2J\x1b[Hvisible inactive split output\r\n' + capturedDataCallback.current?.(redraw) + + expect(pane.terminal.write).not.toHaveBeenCalledWith(redraw, expect.any(Function)) + vi.advanceTimersByTime(0) + expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) + }) + + it('routes visible pane PTY bytes through the background scheduler when the document is hidden', async () => { + ;(globalThis as { document?: Pick }).document = { + visibilityState: 'hidden' + } + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() + capturedDataCallback.current?.('backgrounded document output\r\n') + + expect(pane.terminal.write).not.toHaveBeenCalledWith( + 'backgrounded document output\r\n', + expect.any(Function) + ) + + vi.advanceTimersByTime(50) + expect(pane.terminal.write).toHaveBeenCalledWith('backgrounded document output\r\n') + }) + + it('keeps hidden Codex telemetry startup output parsing briefly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { + command: 'wrapped-agent', + telemetry: { + agent_kind: 'codex', + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b]11;?\x1b\\startup frame\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + + it('does not answer hidden Jcode startup color queries (composer-text leak guard)', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const binding = connectFakePane(connectPanePty, createPane(1), createManager(1), { + isVisibleRef: { current: false }, + startup: { + command: 'jcode', + launchAgent: 'jcode', + telemetry: { + agent_kind: 'jcode', + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + } + }) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]10;?\x1b\\\x1b]11;?\x1b\\startup frame\r\n') + + expect(transport.sendInput).not.toHaveBeenCalledWith( + '\x1b]10;rgb:1111/1111/1111\x1b\\', + expect.anything() + ) + expect(transport.sendInput).not.toHaveBeenCalledWith( + '\x1b]11;rgb:1111/1111/1111\x1b\\', + expect.anything() + ) + + binding.dispose() + }) + + it('keeps hidden Grok telemetry startup output parsing briefly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { + command: 'wrapped-agent', + telemetry: { + agent_kind: 'grok', + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b]11;?\x1b\\startup frame\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + + it('keeps hidden bare Codex startup commands parsing briefly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b]11;?\x1b\\startup frame\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + + it('keeps hidden bare Grok startup commands parsing briefly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: '/Users/me/.grok/bin/grok --permission-mode bypassPermissions' } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b]11;?\x1b\\startup frame\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + + it('skips arbitrary hidden startup output parsing', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'printf noisy startup' } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('hidden startup output\r\n') + + expect(pane.terminal.write).not.toHaveBeenCalledWith( + 'hidden startup output\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + + it('records a mode 2031 subscribe from the raw chunk boundary without answering', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + settings: { ...mockStoreState.settings, theme: 'light' } + } + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[?2031h') + vi.advanceTimersByTime(50) + + // Why the scanner and not xterm's CSI handler: xterm batches PTY chunks into one + // parse, so only this layer knows the chunk ended still subscribed (#9993). + expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) + expect(transport.sendInputImmediate).not.toHaveBeenCalledWith( + expect.stringMatching(/\?997/) + ) + // The bytes still reach xterm so the emulator tracks the mode itself. + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h') + } finally { + vi.useRealTimers() + } + + binding.dispose() + }) + + // Why: fish 4.7.1 enables and disables 2031 around *every* prompt with no opt-out, so + // back-to-back chunks each carrying a toggle are the normal case. Subscribing is never + // answered (#9993) — these pin that silence plus the per-chunk subscription bookkeeping + // that theme-flip pushes depend on. xterm cannot do that bookkeeping: it parses several + // chunks in one synchronous batch and only sees the net result. + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts deleted file mode 100644 index e0778fdffb7..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts +++ /dev/null @@ -1,793 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - createMockTransport, - createPane, - captureCallbackTerminalWrites, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -function expectNoGlobalAtlasRecovery(): void { - // Why: the removed output path requested recovery 200ms before its global reset ran. - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() - expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() -} - -// Why: connectPanePty takes the real pane/manager/deps types and every test in this file -// passes structural fakes. Funnelling the widening through one helper keeps the casts in -// a single reviewable place instead of three per test. -function connectFakePane( - connect: (pane: never, manager: never, deps: never) => { dispose: () => void }, - pane: unknown, - manager: unknown, - deps: Parameters[0] -): { dispose: () => void } { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the three values are this suite's structural fakes for pane/manager/deps; connectPanePty only reads the fields they define. - return connect(pane as never, manager as never, createDeps(deps) as never) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('keeps hidden synchronized output off the global atlas recovery path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - isVisibleRef: { current: false } - }) as never - ) - await flushAsyncTicks(6) - - vi.useFakeTimers() - try { - const startChunk = '\x1b[?2026h' - const plainRowChunk = '| hidden Claude row |\r\n' - const endChunk = '\x1b[?2026l' - - capturedDataCallback.current?.(startChunk) - capturedDataCallback.current?.(plainRowChunk) - capturedDataCallback.current?.(endChunk) - - expect(writes).toEqual([]) - vi.advanceTimersByTime(50) - - expect(writes).toEqual([`${startChunk}${plainRowChunk}${endChunk}`]) - - parseCallbacks[0]?.() - expectNoGlobalAtlasRecovery() - } finally { - vi.useRealTimers() - } - }) - - it('keeps split hidden synchronized output off the global atlas recovery path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - isVisibleRef: { current: false } - }) as never - ) - await flushAsyncTicks(6) - - vi.useFakeTimers() - try { - capturedDataCallback.current?.('\x1b[?202') - capturedDataCallback.current?.('6hbody row\r\n') - capturedDataCallback.current?.('tail\x1b[?20') - capturedDataCallback.current?.('26l') - - vi.advanceTimersByTime(50) - - expect(writes.join('')).toBe('\x1b[?2026hbody row\r\ntail\x1b[?2026l') - - parseCallbacks[0]?.() - expectNoGlobalAtlasRecovery() - } finally { - vi.useRealTimers() - } - }) - - it('does not schedule hidden atlas recovery for ordinary rich text or metadata output', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const { parseCallbacks } = captureCallbackTerminalWrites(pane) - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - isVisibleRef: { current: false } - }) as never - ) - await flushAsyncTicks(6) - - vi.useFakeTimers() - try { - capturedDataCallback.current?.('plain hidden emoji 😀 and CJK 没改什么\r\n') - capturedDataCallback.current?.('\x1b[48;2;52;52;52mcolored shell text\x1b[0m\r\n') - capturedDataCallback.current?.('\x1b]0;hidden title\x07\x1b]133;A\x07') - - vi.advanceTimersByTime(50) - for (const callback of parseCallbacks) { - callback() - } - expectNoGlobalAtlasRecovery() - } finally { - vi.useRealTimers() - } - }) - - it('keeps hidden TUI redraw output off the global atlas recovery path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const { parseCallbacks } = captureCallbackTerminalWrites(pane) - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - isVisibleRef: { current: false } - }) as never - ) - await flushAsyncTicks(6) - - vi.useFakeTimers() - try { - capturedDataCallback.current?.('\x1b[2J\x1b[Hredrawn hidden table\x1b[K') - - vi.advanceTimersByTime(50) - - parseCallbacks[0]?.() - expectNoGlobalAtlasRecovery() - } finally { - vi.useRealTimers() - } - }) - - it('keeps hidden rewrite frames on the pane-local output path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - isVisibleRef: { current: false } - }) as never - ) - await flushAsyncTicks(6) - - vi.useFakeTimers() - try { - capturedDataCallback.current?.('prompt rewrite\r') - vi.advanceTimersByTime(50) - expect(writes).toEqual(['prompt rewrite\r']) - parseCallbacks.shift()?.() - - capturedDataCallback.current?.('\x1b[?2026hredraw frame\x1b[?2026l') - vi.advanceTimersByTime(50) - expect(writes).toEqual(['prompt rewrite\r', '\x1b[?2026hredraw frame\x1b[?2026l']) - parseCallbacks.shift()?.() - - capturedDataCallback.current?.('plain after frame') - vi.advanceTimersByTime(50) - parseCallbacks.shift()?.() - expectNoGlobalAtlasRecovery() - } finally { - vi.useRealTimers() - } - }) - - it('keeps a background-origin alternate-screen chunk in the stream, pane-local', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { background?: boolean }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const isVisibleRef = { current: false } - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - isVisibleRef - }) as never - ) - await flushAsyncTicks(6) - - vi.useFakeTimers() - try { - capturedDataCallback.current?.('\x1b[?2026h') - vi.advanceTimersByTime(50) - parseCallbacks.shift()?.() - writes.length = 0 - - isVisibleRef.current = true - ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' - capturedDataCallback.current?.('\x1b[?2026l', { background: true }) - expect(writes).toEqual([]) - - isVisibleRef.current = false - ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' - capturedDataCallback.current?.('plain after close\r\n') - vi.advanceTimersByTime(50) - parseCallbacks.shift()?.() - - // Dropping the close would strand xterm inside synchronized output. - expect(writes).toEqual(['\x1b[?2026lplain after close\r\n']) - expectNoGlobalAtlasRecovery() - } finally { - vi.useRealTimers() - } - }) - - it('queues visible split-pane PTY bytes when the pane is not active', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isActiveRef: { current: false }, - isVisibleRef: { current: true } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - const redraw = '\x1b[2J\x1b[Hvisible split output\r\n' - capturedDataCallback.current?.(redraw) - - expect(pane.terminal.write).not.toHaveBeenCalledWith(redraw, expect.any(Function)) - vi.advanceTimersByTime(0) - expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) - }) - - it('queues visible ANSI redraws when only another split pane is active', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = { - ...createManager(2), - getActivePane: vi.fn(() => ({ id: 2 })) - } - const deps = createDeps({ - isActiveRef: { current: true }, - isVisibleRef: { current: true } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - const redraw = '\x1b[2J\x1b[Hvisible inactive split output\r\n' - capturedDataCallback.current?.(redraw) - - expect(pane.terminal.write).not.toHaveBeenCalledWith(redraw, expect.any(Function)) - vi.advanceTimersByTime(0) - expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) - }) - - it('routes visible pane PTY bytes through the background scheduler when the document is hidden', async () => { - ;(globalThis as { document?: Pick }).document = { - visibilityState: 'hidden' - } - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isActiveRef: { current: true }, - isVisibleRef: { current: true } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - capturedDataCallback.current?.('backgrounded document output\r\n') - - expect(pane.terminal.write).not.toHaveBeenCalledWith( - 'backgrounded document output\r\n', - expect.any(Function) - ) - - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith('backgrounded document output\r\n') - }) - - it('keeps hidden Codex telemetry startup output parsing briefly', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { - command: 'wrapped-agent', - telemetry: { - agent_kind: 'codex', - launch_source: 'tab_bar_quick_launch', - request_kind: 'new' - } - } - }) as never - ) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') - - expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - '\x1b]11;?\x1b\\startup frame\r\n', - expect.any(Function) - ) - - binding.dispose() - }) - - it('does not answer hidden Jcode startup color queries (composer-text leak guard)', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const binding = connectFakePane(connectPanePty, createPane(1), createManager(1), { - isVisibleRef: { current: false }, - startup: { - command: 'jcode', - launchAgent: 'jcode', - telemetry: { - agent_kind: 'jcode', - launch_source: 'tab_bar_quick_launch', - request_kind: 'new' - } - } - }) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('\x1b]10;?\x1b\\\x1b]11;?\x1b\\startup frame\r\n') - - expect(transport.sendInput).not.toHaveBeenCalledWith( - '\x1b]10;rgb:1111/1111/1111\x1b\\', - expect.anything() - ) - expect(transport.sendInput).not.toHaveBeenCalledWith( - '\x1b]11;rgb:1111/1111/1111\x1b\\', - expect.anything() - ) - - binding.dispose() - }) - - it('keeps hidden Grok telemetry startup output parsing briefly', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { - command: 'wrapped-agent', - telemetry: { - agent_kind: 'grok', - launch_source: 'tab_bar_quick_launch', - request_kind: 'new' - } - } - }) as never - ) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') - - expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - '\x1b]11;?\x1b\\startup frame\r\n', - expect.any(Function) - ) - - binding.dispose() - }) - - it('keeps hidden bare Codex startup commands parsing briefly', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') - - expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - '\x1b]11;?\x1b\\startup frame\r\n', - expect.any(Function) - ) - - binding.dispose() - }) - - it('keeps hidden bare Grok startup commands parsing briefly', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: '/Users/me/.grok/bin/grok --permission-mode bypassPermissions' } - }) as never - ) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') - - expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - '\x1b]11;?\x1b\\startup frame\r\n', - expect.any(Function) - ) - - binding.dispose() - }) - - it('skips arbitrary hidden startup output parsing', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'printf noisy startup' } - }) as never - ) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('hidden startup output\r\n') - - expect(pane.terminal.write).not.toHaveBeenCalledWith( - 'hidden startup output\r\n', - expect.any(Function) - ) - - binding.dispose() - }) - - it('records a mode 2031 subscribe from the raw chunk boundary without answering', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - settings: { ...mockStoreState.settings, theme: 'light' } - } - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ isVisibleRef: { current: false } }) as never - ) - await flushAsyncTicks(6) - - vi.useFakeTimers() - try { - capturedDataCallback.current?.('\x1b[?2031h') - vi.advanceTimersByTime(50) - - // Why the scanner and not xterm's CSI handler: xterm batches PTY chunks into one - // parse, so only this layer knows the chunk ended still subscribed (#9993). - expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - expect(transport.sendInputImmediate).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - // The bytes still reach xterm so the emulator tracks the mode itself. - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h') - } finally { - vi.useRealTimers() - } - - binding.dispose() - }) - - // Why: fish 4.7.1 enables and disables 2031 around *every* prompt with no opt-out, so - // back-to-back chunks each carrying a toggle are the normal case. Subscribing is never - // answered (#9993) — these pin that silence plus the per-chunk subscription bookkeeping - // that theme-flip pushes depend on. xterm cannot do that bookkeeping: it parses several - // chunks in one synchronous batch and only sees the net result. -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.spec.ts new file mode 100644 index 00000000000..00ae7d2b20e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.spec.ts @@ -0,0 +1,533 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_DA1_RESPONSE } from './terminal-capability-replies' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionHiddenBacklogReconciliationTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + describe('hidden-delivery gate', () => { + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } as StoreState['settings'] + } + + async function connectHiddenPane(deps: ReturnType): Promise<{ + transport: MockTransport + pane: ReturnType + dataCallback: ( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void + binding: { syncProcessTracking: () => void; dispose: () => void } + }> { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty(pane as never, manager as never, deps as never) as { + syncProcessTracking: () => void + dispose: () => void + } + await flushAsyncTicks(6) + expect(capturedDataCallback.current).not.toBeNull() + return { transport, pane, dataCallback: capturedDataCallback.current!, binding } + } + + // STA-4042 root: the restore-needed marker is the ONE point where "bytes + // were dropped" is known. The emulator carries state across chunks just like + // the cross-chunk parser the handler already resets, so the gap is closed + // here rather than left to each recovery path to remember. + + describe('foreground flood restore feedback loop (rc.7.perf)', () => { + function writtenFloodData(pane: ReturnType): string { + return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + } + + async function startInFlightRestore(): Promise<{ + pane: ReturnType + transport: MockTransport + dataCallback: ( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void + getMainBufferSnapshot: ReturnType + resolveFirstSnapshot: (snapshot: { + data: string + cols: number + rows: number + seq: number + }) => void + }> { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { pane, transport, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: installTerminalTestGlobals installs getMainBufferSnapshot as a vi.fn mock. + const getMainBufferSnapshot = window.api.pty + .getMainBufferSnapshot as unknown as ReturnType + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + getMainBufferSnapshot.mockReturnValueOnce(firstSnapshot.promise).mockResolvedValue({ + data: 'repaint snapshot\r\n', + cols: 100, + rows: 30, + seq: 5_000_000 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 64 + }) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + return { + pane, + transport, + dataCallback, + getMainBufferSnapshot, + resolveFirstSnapshot: (snapshot) => firstSnapshot.resolve(snapshot) + } + } + + it('abandons the restore on queue overflow, writes the stream through, and repaints once', async () => { + const { pane, dataCallback, getMainBufferSnapshot, resolveFirstSnapshot } = + await startInFlightRestore() + const { markTerminalFollowOutput, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const parseCallbacks: (() => void)[] = [] + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { + if (callback) { + parseCallbacks.push(callback) + } + }) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + markTerminalPinnedViewport(pane.terminal) + + // Flood while the snapshot is in flight: overflows the 512KB restore queue. + dataCallback('f'.repeat(300 * 1024), { seq: 300 * 1024 + 64, rawLength: 300 * 1024 }) + dataCallback('g'.repeat(300 * 1024), { seq: 600 * 1024 + 64, rawLength: 300 * 1024 }) + + try { + vi.useFakeTimers() + resolveFirstSnapshot({ data: 'flood snapshot\r\n', cols: 100, rows: 30, seq: 64 }) + await flushAsyncTicks(20) + + // Cut 1: the overflow abandons the restore instead of re-fetching. + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + pane.terminal.buffer.active.viewportY = 200 + pane.terminal.buffer.active.baseY = 200 + for (const callback of parseCallbacks.splice(0)) { + callback() + } + // The post-replay fit is part of the transaction now; let its promise settle before asserting. + await flushAsyncTicks(20) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(142) + + // Cut 2: drop sentinels and seq-gap chunks during the flood window must not re-arm restores — post-gap bytes write through. + dataCallback('', { droppedOutput: true }) + dataCallback('AFTER-FLOOD', { seq: 700 * 1024, rawLength: 11 }) + await flushAsyncTicks(8) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(writtenFloodData(pane)).toContain('AFTER-FLOOD') + + // The repaint only runs while the viewport follows output; return there first. + markTerminalFollowOutput(pane.terminal) + + // After the flood goes quiet: exactly ONE deferred repaint. + vi.advanceTimersByTime(2_100) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(5_000) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('holds the post-flood repaint while the user reads scrollback and runs it on return to the bottom', async () => { + const { pane, dataCallback, getMainBufferSnapshot, resolveFirstSnapshot } = + await startInFlightRestore() + const { markTerminalFollowOutput, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + markTerminalPinnedViewport(pane.terminal) + + dataCallback('f'.repeat(300 * 1024), { seq: 300 * 1024 + 64, rawLength: 300 * 1024 }) + dataCallback('g'.repeat(300 * 1024), { seq: 600 * 1024 + 64, rawLength: 300 * 1024 }) + + try { + vi.useFakeTimers() + resolveFirstSnapshot({ data: 'flood snapshot\r\n', cols: 100, rows: 30, seq: 64 }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + // Quiet flood, but the user is still scrolled back: the clear-and-replay repaint must not move their viewport. + vi.advanceTimersByTime(2_100) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(60_000) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + // Returning to the bottom is the event that releases it — the heal is deferred, never dropped. + pane.terminal.buffer.active.viewportY = pane.terminal.buffer.active.baseY + markTerminalFollowOutput(pane.terminal) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('sends salvaged queries immediately from an overflowing restore queue', async () => { + const { pane, transport, dataCallback } = await startInFlightRestore() + + // Queue 400KB, then overflow with color/CPR/DA probes — the discarded probes need direct replies before their read windows close. + dataCallback('a'.repeat(400 * 1024), { seq: 400 * 1024, rawLength: 400 * 1024 }) + const queries = '\x1b]11;?\x1b\\\x1b[6n\x1b[c' + dataCallback(`${'b'.repeat(200 * 1024)}${queries}`, { + seq: 600 * 1024 + queries.length, + rawLength: 200 * 1024 + queries.length + }) + await flushAsyncTicks(8) + + const replies = transport.sendInputImmediate.mock.calls.map((call) => String(call[0])) + // oxlint-disable-next-line no-control-regex -- the ESC byte IS the payload: this matches the CPR reply + const cprReply = replies.find((reply) => /^\u001b\[\d+;\d+R$/.test(reply)) + const oscReply = replies.find((reply) => reply.startsWith('\x1b]11;rgb:')) + expect(oscReply).toBeDefined() + expect(cprReply).toBeDefined() + expect(replies).toEqual([oscReply, cprReply, DEFAULT_DA1_RESPONSE]) + const written = writtenFloodData(pane) + expect(written).not.toContain('aaaa') + expect(written).not.toContain('bbbb') + }) + + it('keeps the hidden-pane drop sentinel arming a reveal restore (gate unchanged)', async () => { + enableMainAuthority() + const isVisibleRef = { current: false } + const deps = createDeps({ isVisibleRef }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: installTerminalTestGlobals installs getMainBufferSnapshot as a vi.fn mock. + const getMainBufferSnapshot = window.api.pty + .getMainBufferSnapshot as unknown as ReturnType + getMainBufferSnapshot.mockResolvedValue({ + data: 'hidden reveal snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + + // Hidden pane: the sentinel latches restore-needed but must not fetch. + dataCallback('', { droppedOutput: true }) + await flushAsyncTicks(8) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + + // Reveal: the latched restore fetches exactly one snapshot. + isVisibleRef.current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(writtenFloodData(pane)).toContain('hidden reveal snapshot') + }) + }) + + describe('post-restore backlog reconciliation', () => { + async function restoreVisiblePaneToBaseline(): Promise<{ + pane: ReturnType + dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void + getMainBufferSnapshot: ReturnType + transport: MockTransport + deps: ReturnType + }> { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { pane, dataCallback, transport } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: installTerminalTestGlobals installs getMainBufferSnapshot as a vi.fn mock. + const getMainBufferSnapshot = window.api.pty + .getMainBufferSnapshot as unknown as ReturnType + getMainBufferSnapshot.mockResolvedValue({ + data: 'restored snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 64 + }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('restored snapshot'), + expect.any(Function) + ) + pane.terminal.write.mockClear() + return { pane, dataCallback, getMainBufferSnapshot, transport, deps } + } + + function writtenData(pane: ReturnType): string { + return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + } + + it('drops backlog chunks the restored snapshot already covers', async () => { + const { pane, dataCallback } = await restoreVisiblePaneToBaseline() + + // Whole chunk at or before the baseline seq: duplicate, never written. + dataCallback('OLD-DUPLICATE', { seq: 60, rawLength: 13 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('OLD-DUPLICATE') + + // Contiguous post-baseline chunk flows through normally. + dataCallback('NEW', { seq: 67, rawLength: 3 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('NEW') + }) + + it('records a 2031 subscribe on a chunk the restored snapshot drops as duplicate', async () => { + // Why the scan runs before reconciliation: the snapshot restore replays bytes into + // xterm without tracking modes, so this live delivery is the only chance to observe + // the subscription. Dropping the chunk as a duplicate must not drop that. + // Gate off so the chunk scanner (not main's fact) owns this pane's registry. + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } as StoreState['settings'] + const { pane, dataCallback, transport, deps } = await restoreVisiblePaneToBaseline() + transport.sendInput.mockClear() + + dataCallback(`SUB\x1b[?2031h`, { seq: 60, rawLength: 11 }) + await flushAsyncTicks(8) + + expect(writtenData(pane)).not.toContain('SUB') + expect(deps.paneMode2031Ref.current.get(1)).toBe(true) + expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) + }) + + it('slices a partial overlap when raw and clean lengths match', async () => { + const { pane, dataCallback } = await restoreVisiblePaneToBaseline() + + // start seq 61 < baseline 64 < end seq 67 — only the last 3 chars are new. + dataCallback('ABCDEF', { seq: 67, rawLength: 6 }) + await flushAsyncTicks(8) + + const written = writtenData(pane) + expect(written).toContain('DEF') + expect(written).not.toContain('ABC') + }) + + it('forces a fresh snapshot for an overlap whose offsets cannot be mapped', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'second snapshot\r\n', + cols: 100, + rows: 30, + seq: 80 + }) + + // rawLength (6) !== data.length (4): OSC stripping makes the slice offset unmappable — restore from a fresh snapshot instead. + dataCallback('ABCD', { seq: 67, rawLength: 6 }) + await flushAsyncTicks(20) + + expect(writtenData(pane)).not.toContain('ABCD') + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writtenData(pane)).toContain('second snapshot') + }) + + it('detects a seq gap after restore and forces another restore', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'gap-heal snapshot\r\n', + cols: 100, + rows: 30, + seq: 120 + }) + + // Why: a chunk starting past the continuity point means main trimmed bytes after the overflow marker — only the model snapshot can heal the gap. + dataCallback('AFTER-GAP', { seq: 96, rawLength: 9 }) + await flushAsyncTicks(20) + + expect(writtenData(pane)).not.toContain('AFTER-GAP') + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writtenData(pane)).toContain('gap-heal snapshot') + }) + + it('writes genuinely-new live output whose seq sits below an empty-backlog baseline', async () => { + // E2E twin: a live chunk's seq sits below main's cumulative baseline, but with an empty pending queue main can never re-deliver — so it must write, not drop. + enableMainAuthority() + const isVisibleRef = { current: true } + const deps = createDeps({ isVisibleRef }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: installTerminalTestGlobals installs getMainBufferSnapshot as a vi.fn mock. + const getMainBufferSnapshot = window.api.pty + .getMainBufferSnapshot as unknown as ReturnType + // Visible prompt echo metered in main's cumulative seq domain. + dataCallback('$ node frame-script.mjs\r\n', { seq: 2_315, rawLength: 25 }) + // Pane hides mid-stream; main drops the hidden frame and marks restore. + isVisibleRef.current = false + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: 2_472 + }) + // Reveal: the snapshot covers everything ingested; pending queue empty (pendingDeliveryStartSeq === seq). + getMainBufferSnapshot.mockResolvedValue({ + data: 'LOW_RISK_RESTORE_FRAME_40\r\n', + cols: 100, + rows: 30, + seq: 2_472, + pendingDeliveryStartSeq: 2_472 + }) + isVisibleRef.current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_40') + pane.terminal.write.mockClear() + + // Newer live frame injected with a seq domain unrelated to main's counter (e2e __terminalPtyDataInjection twin). + dataCallback('LOW_RISK_RESTORE_FRAME_41\r\n', { seq: 315, rawLength: 27 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_41') + + // The retired baseline keeps subsequent low-seq live chunks flowing. + dataCallback('progress=041\r\n', { seq: 329, rawLength: 14 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('progress=041') + }) + + it('keeps suppressing backlog duplicates inside the reported pending window', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'windowed snapshot\r\n', + cols: 100, + rows: 30, + seq: 96, + pendingDeliveryStartSeq: 80 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 96 + }) + await flushAsyncTicks(20) + expect(writtenData(pane)).toContain('windowed snapshot') + pane.terminal.write.mockClear() + + // Inside the pending window (80, 96]: a draining backlog duplicate. + dataCallback('IN-WINDOW-DUP-16', { seq: 96, rawLength: 16 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('IN-WINDOW-DUP-16') + + // Past the baseline: genuinely-new live output still flows. + dataCallback('PAST-BASELINE', { seq: 109, rawLength: 13 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('PAST-BASELINE') + + // Below the pending window (≤ 80): main can never re-send these seqs, so this foreign seq domain is written, never dropped. + dataCallback('BELOW-WINDOW', { seq: 60, rawLength: 12 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('BELOW-WINDOW') + }) + }) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.test.ts deleted file mode 100644 index edcc0f65849..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.test.ts +++ /dev/null @@ -1,621 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { DEFAULT_DA1_RESPONSE } from './terminal-capability-replies' -import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' -import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - describe('hidden-delivery gate', () => { - function enableMainAuthority(): void { - mockStoreState.settings = { - ...mockStoreState.settings, - terminalMainSideEffectAuthority: true - } as StoreState['settings'] - } - - async function connectHiddenPane(deps: ReturnType): Promise<{ - transport: MockTransport - pane: ReturnType - dataCallback: ( - data: string, - meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } - ) => void - binding: { syncProcessTracking: () => void; dispose: () => void } - }> { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: - | (( - data: string, - meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } - ) => void) - | null - } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty(pane as never, manager as never, deps as never) as { - syncProcessTracking: () => void - dispose: () => void - } - await flushAsyncTicks(6) - expect(capturedDataCallback.current).not.toBeNull() - return { transport, pane, dataCallback: capturedDataCallback.current!, binding } - } - - // STA-4042 root: the restore-needed marker is the ONE point where "bytes - // were dropped" is known. The emulator carries state across chunks just like - // the cross-chunk parser the handler already resets, so the gap is closed - // here rather than left to each recovery path to remember. - - describe('foreground flood restore feedback loop (rc.7.perf)', () => { - function writtenFloodData(pane: ReturnType): string { - return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') - } - - async function startInFlightRestore(): Promise<{ - pane: ReturnType - transport: MockTransport - dataCallback: ( - data: string, - meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } - ) => void - getMainBufferSnapshot: ReturnType - resolveFirstSnapshot: (snapshot: { - data: string - cols: number - rows: number - seq: number - }) => void - }> { - enableMainAuthority() - const deps = createDeps({ isVisibleRef: { current: true } }) - const { pane, transport, dataCallback } = await connectHiddenPane(deps) - const transportOptions = createdTransportOptions.at(-1) as { - onPtySpawn?: (ptyId: string) => void - } - transportOptions.onPtySpawn?.('pty-id') - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const firstSnapshot = createDeferred<{ - data: string - cols: number - rows: number - seq: number - }>() - getMainBufferSnapshot - .mockReturnValueOnce(firstSnapshot.promise) - .mockResolvedValue({ data: 'repaint snapshot\r\n', cols: 100, rows: 30, seq: 5_000_000 }) - const { _dispatchPtyModelRestoreNeededForTest } = - await import('./pty-model-restore-channel') - _dispatchPtyModelRestoreNeededForTest({ - id: 'pty-id', - reason: 'pending-cap', - markerSeq: 64 - }) - await flushAsyncTicks(4) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - return { - pane, - transport, - dataCallback, - getMainBufferSnapshot, - resolveFirstSnapshot: (snapshot) => firstSnapshot.resolve(snapshot) - } - } - - it('abandons the restore on queue overflow, writes the stream through, and repaints once', async () => { - const { pane, dataCallback, getMainBufferSnapshot, resolveFirstSnapshot } = - await startInFlightRestore() - const { markTerminalFollowOutput, markTerminalPinnedViewport } = - await import('@/lib/pane-manager/terminal-scroll-intent') - const parseCallbacks: (() => void)[] = [] - pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { - if (callback) { - parseCallbacks.push(callback) - } - }) - pane.terminal.buffer.active.viewportY = 42 - pane.terminal.buffer.active.baseY = 100 - markTerminalPinnedViewport(pane.terminal) - - // Flood while the snapshot is in flight: overflows the 512KB restore queue. - dataCallback('f'.repeat(300 * 1024), { seq: 300 * 1024 + 64, rawLength: 300 * 1024 }) - dataCallback('g'.repeat(300 * 1024), { seq: 600 * 1024 + 64, rawLength: 300 * 1024 }) - - try { - vi.useFakeTimers() - resolveFirstSnapshot({ data: 'flood snapshot\r\n', cols: 100, rows: 30, seq: 64 }) - await flushAsyncTicks(20) - - // Cut 1: the overflow abandons the restore instead of re-fetching. - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - pane.terminal.buffer.active.viewportY = 200 - pane.terminal.buffer.active.baseY = 200 - for (const callback of parseCallbacks.splice(0)) { - callback() - } - // The post-replay fit is part of the transaction now; let its promise settle before asserting. - await flushAsyncTicks(20) - expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(142) - - // Cut 2: drop sentinels and seq-gap chunks during the flood window must not re-arm restores — post-gap bytes write through. - dataCallback('', { droppedOutput: true }) - dataCallback('AFTER-FLOOD', { seq: 700 * 1024, rawLength: 11 }) - await flushAsyncTicks(8) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - expect(writtenFloodData(pane)).toContain('AFTER-FLOOD') - - // The repaint only runs while the viewport follows output; return there first. - markTerminalFollowOutput(pane.terminal) - - // After the flood goes quiet: exactly ONE deferred repaint. - vi.advanceTimersByTime(2_100) - await flushAsyncTicks(20) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) - vi.advanceTimersByTime(5_000) - await flushAsyncTicks(20) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - - it('holds the post-flood repaint while the user reads scrollback and runs it on return to the bottom', async () => { - const { pane, dataCallback, getMainBufferSnapshot, resolveFirstSnapshot } = - await startInFlightRestore() - const { markTerminalFollowOutput, markTerminalPinnedViewport } = - await import('@/lib/pane-manager/terminal-scroll-intent') - pane.terminal.buffer.active.viewportY = 42 - pane.terminal.buffer.active.baseY = 100 - markTerminalPinnedViewport(pane.terminal) - - dataCallback('f'.repeat(300 * 1024), { seq: 300 * 1024 + 64, rawLength: 300 * 1024 }) - dataCallback('g'.repeat(300 * 1024), { seq: 600 * 1024 + 64, rawLength: 300 * 1024 }) - - try { - vi.useFakeTimers() - resolveFirstSnapshot({ data: 'flood snapshot\r\n', cols: 100, rows: 30, seq: 64 }) - await flushAsyncTicks(20) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - - // Quiet flood, but the user is still scrolled back: the clear-and-replay repaint must not move their viewport. - vi.advanceTimersByTime(2_100) - await flushAsyncTicks(20) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - vi.advanceTimersByTime(60_000) - await flushAsyncTicks(20) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - - // Returning to the bottom is the event that releases it — the heal is deferred, never dropped. - pane.terminal.buffer.active.viewportY = pane.terminal.buffer.active.baseY - markTerminalFollowOutput(pane.terminal) - await flushAsyncTicks(20) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - - it('sends salvaged queries immediately from an overflowing restore queue', async () => { - const { pane, transport, dataCallback } = await startInFlightRestore() - - // Queue 400KB, then overflow with color/CPR/DA probes — the discarded probes need direct replies before their read windows close. - dataCallback('a'.repeat(400 * 1024), { seq: 400 * 1024, rawLength: 400 * 1024 }) - const queries = '\x1b]11;?\x1b\\\x1b[6n\x1b[c' - dataCallback(`${'b'.repeat(200 * 1024)}${queries}`, { - seq: 600 * 1024 + queries.length, - rawLength: 200 * 1024 + queries.length - }) - await flushAsyncTicks(8) - - const replies = transport.sendInputImmediate.mock.calls.map((call) => String(call[0])) - // oxlint-disable-next-line no-control-regex -- the ESC byte IS the payload: this matches the CPR reply - const cprReply = replies.find((reply) => /^\u001b\[\d+;\d+R$/.test(reply)) - const oscReply = replies.find((reply) => reply.startsWith('\x1b]11;rgb:')) - expect(oscReply).toBeDefined() - expect(cprReply).toBeDefined() - expect(replies).toEqual([oscReply, cprReply, DEFAULT_DA1_RESPONSE]) - const written = writtenFloodData(pane) - expect(written).not.toContain('aaaa') - expect(written).not.toContain('bbbb') - }) - - it('keeps the hidden-pane drop sentinel arming a reveal restore (gate unchanged)', async () => { - enableMainAuthority() - const isVisibleRef = { current: false } - const deps = createDeps({ isVisibleRef }) - const { pane, dataCallback } = await connectHiddenPane(deps) - const transportOptions = createdTransportOptions.at(-1) as { - onPtySpawn?: (ptyId: string) => void - } - transportOptions.onPtySpawn?.('pty-id') - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'hidden reveal snapshot\r\n', - cols: 100, - rows: 30, - seq: 64 - }) - - // Hidden pane: the sentinel latches restore-needed but must not fetch. - dataCallback('', { droppedOutput: true }) - await flushAsyncTicks(8) - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - - // Reveal: the latched restore fetches exactly one snapshot. - isVisibleRef.current = true - const { requestTerminalBacklogRecovery } = - await import('@/lib/pane-manager/pane-terminal-output-scheduler') - requestTerminalBacklogRecovery(pane.terminal as never) - await flushAsyncTicks(20) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - expect(writtenFloodData(pane)).toContain('hidden reveal snapshot') - }) - }) - - describe('post-restore backlog reconciliation', () => { - async function restoreVisiblePaneToBaseline(): Promise<{ - pane: ReturnType - dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void - getMainBufferSnapshot: ReturnType - transport: MockTransport - deps: ReturnType - }> { - enableMainAuthority() - const deps = createDeps({ isVisibleRef: { current: true } }) - const { pane, dataCallback, transport } = await connectHiddenPane(deps) - const transportOptions = createdTransportOptions.at(-1) as { - onPtySpawn?: (ptyId: string) => void - } - transportOptions.onPtySpawn?.('pty-id') - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'restored snapshot\r\n', - cols: 100, - rows: 30, - seq: 64 - }) - const { _dispatchPtyModelRestoreNeededForTest } = - await import('./pty-model-restore-channel') - _dispatchPtyModelRestoreNeededForTest({ - id: 'pty-id', - reason: 'pending-cap', - markerSeq: 64 - }) - await flushAsyncTicks(20) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - expect(pane.terminal.write).toHaveBeenCalledWith( - expect.stringContaining('restored snapshot'), - expect.any(Function) - ) - pane.terminal.write.mockClear() - return { pane, dataCallback, getMainBufferSnapshot, transport, deps } - } - - function writtenData(pane: ReturnType): string { - return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') - } - - it('drops backlog chunks the restored snapshot already covers', async () => { - const { pane, dataCallback } = await restoreVisiblePaneToBaseline() - - // Whole chunk at or before the baseline seq: duplicate, never written. - dataCallback('OLD-DUPLICATE', { seq: 60, rawLength: 13 }) - await flushAsyncTicks(8) - expect(writtenData(pane)).not.toContain('OLD-DUPLICATE') - - // Contiguous post-baseline chunk flows through normally. - dataCallback('NEW', { seq: 67, rawLength: 3 }) - await flushAsyncTicks(8) - expect(writtenData(pane)).toContain('NEW') - }) - - it('records a 2031 subscribe on a chunk the restored snapshot drops as duplicate', async () => { - // Why the scan runs before reconciliation: the snapshot restore replays bytes into - // xterm without tracking modes, so this live delivery is the only chance to observe - // the subscription. Dropping the chunk as a duplicate must not drop that. - // Gate off so the chunk scanner (not main's fact) owns this pane's registry. - enableMainAuthority() - mockStoreState.settings = { - ...mockStoreState.settings, - terminalHiddenDeliveryGate: false - } as StoreState['settings'] - const { pane, dataCallback, transport, deps } = await restoreVisiblePaneToBaseline() - transport.sendInput.mockClear() - - dataCallback(`SUB\x1b[?2031h`, { seq: 60, rawLength: 11 }) - await flushAsyncTicks(8) - - expect(writtenData(pane)).not.toContain('SUB') - expect(deps.paneMode2031Ref.current.get(1)).toBe(true) - expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - }) - - it('slices a partial overlap when raw and clean lengths match', async () => { - const { pane, dataCallback } = await restoreVisiblePaneToBaseline() - - // start seq 61 < baseline 64 < end seq 67 — only the last 3 chars are new. - dataCallback('ABCDEF', { seq: 67, rawLength: 6 }) - await flushAsyncTicks(8) - - const written = writtenData(pane) - expect(written).toContain('DEF') - expect(written).not.toContain('ABC') - }) - - it('forces a fresh snapshot for an overlap whose offsets cannot be mapped', async () => { - const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() - getMainBufferSnapshot.mockResolvedValue({ - data: 'second snapshot\r\n', - cols: 100, - rows: 30, - seq: 80 - }) - - // rawLength (6) !== data.length (4): OSC stripping makes the slice offset unmappable — restore from a fresh snapshot instead. - dataCallback('ABCD', { seq: 67, rawLength: 6 }) - await flushAsyncTicks(20) - - expect(writtenData(pane)).not.toContain('ABCD') - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) - expect(writtenData(pane)).toContain('second snapshot') - }) - - it('detects a seq gap after restore and forces another restore', async () => { - const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() - getMainBufferSnapshot.mockResolvedValue({ - data: 'gap-heal snapshot\r\n', - cols: 100, - rows: 30, - seq: 120 - }) - - // Why: a chunk starting past the continuity point means main trimmed bytes after the overflow marker — only the model snapshot can heal the gap. - dataCallback('AFTER-GAP', { seq: 96, rawLength: 9 }) - await flushAsyncTicks(20) - - expect(writtenData(pane)).not.toContain('AFTER-GAP') - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) - expect(writtenData(pane)).toContain('gap-heal snapshot') - }) - - it('writes genuinely-new live output whose seq sits below an empty-backlog baseline', async () => { - // E2E twin: a live chunk's seq sits below main's cumulative baseline, but with an empty pending queue main can never re-deliver — so it must write, not drop. - enableMainAuthority() - const isVisibleRef = { current: true } - const deps = createDeps({ isVisibleRef }) - const { pane, dataCallback } = await connectHiddenPane(deps) - const transportOptions = createdTransportOptions.at(-1) as { - onPtySpawn?: (ptyId: string) => void - } - transportOptions.onPtySpawn?.('pty-id') - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - // Visible prompt echo metered in main's cumulative seq domain. - dataCallback('$ node frame-script.mjs\r\n', { seq: 2_315, rawLength: 25 }) - // Pane hides mid-stream; main drops the hidden frame and marks restore. - isVisibleRef.current = false - const { _dispatchPtyModelRestoreNeededForTest } = - await import('./pty-model-restore-channel') - _dispatchPtyModelRestoreNeededForTest({ - id: 'pty-id', - reason: 'hidden-drop', - markerSeq: 2_472 - }) - // Reveal: the snapshot covers everything ingested; pending queue empty (pendingDeliveryStartSeq === seq). - getMainBufferSnapshot.mockResolvedValue({ - data: 'LOW_RISK_RESTORE_FRAME_40\r\n', - cols: 100, - rows: 30, - seq: 2_472, - pendingDeliveryStartSeq: 2_472 - }) - isVisibleRef.current = true - const { requestTerminalBacklogRecovery } = - await import('@/lib/pane-manager/pane-terminal-output-scheduler') - requestTerminalBacklogRecovery(pane.terminal as never) - await flushAsyncTicks(20) - expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_40') - pane.terminal.write.mockClear() - - // Newer live frame injected with a seq domain unrelated to main's counter (e2e __terminalPtyDataInjection twin). - dataCallback('LOW_RISK_RESTORE_FRAME_41\r\n', { seq: 315, rawLength: 27 }) - await flushAsyncTicks(8) - expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_41') - - // The retired baseline keeps subsequent low-seq live chunks flowing. - dataCallback('progress=041\r\n', { seq: 329, rawLength: 14 }) - await flushAsyncTicks(8) - expect(writtenData(pane)).toContain('progress=041') - }) - - it('keeps suppressing backlog duplicates inside the reported pending window', async () => { - const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() - getMainBufferSnapshot.mockResolvedValue({ - data: 'windowed snapshot\r\n', - cols: 100, - rows: 30, - seq: 96, - pendingDeliveryStartSeq: 80 - }) - const { _dispatchPtyModelRestoreNeededForTest } = - await import('./pty-model-restore-channel') - _dispatchPtyModelRestoreNeededForTest({ - id: 'pty-id', - reason: 'pending-cap', - markerSeq: 96 - }) - await flushAsyncTicks(20) - expect(writtenData(pane)).toContain('windowed snapshot') - pane.terminal.write.mockClear() - - // Inside the pending window (80, 96]: a draining backlog duplicate. - dataCallback('IN-WINDOW-DUP-16', { seq: 96, rawLength: 16 }) - await flushAsyncTicks(8) - expect(writtenData(pane)).not.toContain('IN-WINDOW-DUP-16') - - // Past the baseline: genuinely-new live output still flows. - dataCallback('PAST-BASELINE', { seq: 109, rawLength: 13 }) - await flushAsyncTicks(8) - expect(writtenData(pane)).toContain('PAST-BASELINE') - - // Below the pending window (≤ 80): main can never re-send these seqs, so this foreign seq domain is written, never dropped. - dataCallback('BELOW-WINDOW', { seq: 60, rawLength: 12 }) - await flushAsyncTicks(8) - expect(writtenData(pane)).toContain('BELOW-WINDOW') - }) - }) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.spec.ts new file mode 100644 index 00000000000..7a9efa07d24 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.spec.ts @@ -0,0 +1,696 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + POST_REPLAY_DEAD_TUI_RESET, + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + POST_REPLAY_LIVE_SNAPSHOT_RESET, + POST_REPLAY_REATTACH_RESET +} from '../../../../shared/terminal-mode-reset-profiles' +import { replayEpilogue } from './pty-connection-test-replay-epilogue' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionHiddenCodexQueriesTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('keeps hidden Codex redraw floods off the live xterm path', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + const hiddenCodexRedraw = `\x1b[?2026h\x1b[2J\x1b[H${'codex redraw '.repeat(8_000)}` + capturedDataCallback.current?.(hiddenCodexRedraw) + vi.advanceTimersByTime(50) + + expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenCodexRedraw) + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + + binding.dispose() + }) + + it.each(['codex', 'sleep 30 && codex', 'sleep 30 && claude'])( + 'keeps hidden %s terminal query chunks on the live xterm path', + async (command) => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection fixture supplies the hidden pane APIs with this explicit startup command. + createDeps({ + isVisibleRef: { current: false }, + startup: { command } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[c') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) + + binding.dispose() + } + ) + + it('keeps only coalesced hidden Codex terminal queries on the live xterm path', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + const coalescedChunk = `\x1b[c\x1b[?2026h\x1b[2J\x1b[H${'codex redraw '.repeat(8_000)}` + capturedDataCallback.current?.(coalescedChunk) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(coalescedChunk, expect.any(Function)) + + binding.dispose() + }) + + it('answers hidden OSC color queries directly inside a mixed capability-query burst', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + const queryBurst = '\x1b[c\x1b]11;?\x1b\\\x1b[>q\x1b[14t\x1b[16t' + const coalescedChunk = `${queryBurst}\x1b[?2026h${'codex redraw '.repeat(8_000)}` + capturedDataCallback.current?.(coalescedChunk) + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[c\x1b[>q\x1b[14t\x1b[16t', + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith(coalescedChunk, expect.any(Function)) + + binding.dispose() + }) + + it('answers adjacent hidden Codex OSC color queries directly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + const queries = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\' + capturedDataCallback.current?.(`${queries}startup frame\r\n`) + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') + expect(pane.terminal.write).not.toHaveBeenCalledWith(queries, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + `${queries}startup frame\r\n`, + expect.any(Function) + ) + + binding.dispose() + }) + + it('keeps split hidden Codex terminal queries on the live xterm path', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[') + capturedDataCallback.current?.(`c\x1b[?2026h${'codex redraw '.repeat(8_000)}`) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + `c\x1b[?2026h${'codex redraw '.repeat(8_000)}`, + expect.any(Function) + ) + + binding.dispose() + }) + + it('keeps hidden Codex terminal queries split after ESC on the live xterm path', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b') + capturedDataCallback.current?.(`[c\x1b[?2026h${'codex redraw '.repeat(8_000)}`) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + `[c\x1b[?2026h${'codex redraw '.repeat(8_000)}`, + expect.any(Function) + ) + + binding.dispose() + }) + + it('flushes pending hidden Codex query prefixes when the pane becomes visible', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b') + isVisibleRef.current = true + capturedDataCallback.current?.('[c') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) + } finally { + binding.dispose() + } + }) + + it('keeps split hidden-to-visible Codex stateful queries behind snapshot restore', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-before-cpr\r\n', + cols: 100, + rows: 30 + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + 'snapshot-before-cpr\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[6n', expect.any(Function)) + } finally { + binding.dispose() + } + }) + + it('preserves live agent cursor and focus modes on hidden-to-visible snapshot restore', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'agent-frame\x1b[?25l', + cols: 100, + rows: 30, + alternateScreen: true + }) + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: '', + agentType: 'codex', + paneKey, + terminalTitle: 'codex', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + + const isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + // A live agent owns ?1004h (focus reporting); the plain reset's ?1004l would silence focus events until restart, since agents only enable it at startup. + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('lets fresh host shell proof outrank stale live-agent metadata on reveal', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'stale-agent-frame\x1b[?25l', + cols: 100, + rows: 30, + alternateScreen: true, + terminalOwner: 'shell' + }) + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: '', + agentType: 'codex', + paneKey, + terminalTitle: 'codex', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + + const isVisibleRef = { current: false } + const pane = createPane(1) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ isVisibleRef, startup: { command: 'codex' } }) as never + ) + try { + await flushAsyncTicks(6) + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET), + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('preserves current behavior when a snapshot has no host ownership proof', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'shell-frame\x1b[?25l', + cols: 100, + rows: 30, + alternateScreen: false + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_REATTACH_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('keeps a live alternate-screen pane interactive when a legacy snapshot omits its mode flag', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'legacy-tui-frame', + cols: 100, + rows: 30 + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + pane.terminal.buffer.active.type = 'alternate' + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_REATTACH_RESET), + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('grounds a stale alternate-screen snapshot when the host proves shell ownership', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'stale-tui-frame', + cols: 100, + rows: 30, + alternateScreen: true, + terminalOwner: 'shell' + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + pane.terminal.buffer.active.type = 'alternate' + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ isVisibleRef, startup: { command: 'codex' } }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET), + expect.any(Function) + ) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() + } finally { + binding.dispose() + } + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts deleted file mode 100644 index 449e72ed704..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts +++ /dev/null @@ -1,761 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - POST_REPLAY_DEAD_TUI_RESET, - POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, - POST_REPLAY_LIVE_SNAPSHOT_RESET, - POST_REPLAY_REATTACH_RESET -} from '../../../../shared/terminal-mode-reset-profiles' -import { replayEpilogue } from './pty-connection-test-replay-epilogue' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('keeps hidden Codex redraw floods off the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - vi.useFakeTimers() - try { - const hiddenCodexRedraw = `\x1b[?2026h\x1b[2J\x1b[H${'codex redraw '.repeat(8_000)}` - capturedDataCallback.current?.(hiddenCodexRedraw) - vi.advanceTimersByTime(50) - - expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenCodexRedraw) - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - - binding.dispose() - }) - - it.each(['codex', 'sleep 30 && codex', 'sleep 30 && claude'])( - 'keeps hidden %s terminal query chunks on the live xterm path', - async (command) => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection fixture supplies the hidden pane APIs with this explicit startup command. - createDeps({ - isVisibleRef: { current: false }, - startup: { command } - }) as never - ) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[c') - - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) - - binding.dispose() - } - ) - - it('keeps only coalesced hidden Codex terminal queries on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - const coalescedChunk = `\x1b[c\x1b[?2026h\x1b[2J\x1b[H${'codex redraw '.repeat(8_000)}` - capturedDataCallback.current?.(coalescedChunk) - - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith(coalescedChunk, expect.any(Function)) - - binding.dispose() - }) - - it('answers hidden OSC color queries directly inside a mixed capability-query burst', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - const queryBurst = '\x1b[c\x1b]11;?\x1b\\\x1b[>q\x1b[14t\x1b[16t' - const coalescedChunk = `${queryBurst}\x1b[?2026h${'codex redraw '.repeat(8_000)}` - capturedDataCallback.current?.(coalescedChunk) - - expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b[c\x1b[>q\x1b[14t\x1b[16t', - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith(coalescedChunk, expect.any(Function)) - - binding.dispose() - }) - - it('answers adjacent hidden Codex OSC color queries directly', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - const queries = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\' - capturedDataCallback.current?.(`${queries}startup frame\r\n`) - - expect(transport.sendInput).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - expect(pane.terminal.write).not.toHaveBeenCalledWith(queries, expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - `${queries}startup frame\r\n`, - expect.any(Function) - ) - - binding.dispose() - }) - - it('keeps split hidden Codex terminal queries on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[') - capturedDataCallback.current?.(`c\x1b[?2026h${'codex redraw '.repeat(8_000)}`) - - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - `c\x1b[?2026h${'codex redraw '.repeat(8_000)}`, - expect.any(Function) - ) - - binding.dispose() - }) - - it('keeps hidden Codex terminal queries split after ESC on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b') - capturedDataCallback.current?.(`[c\x1b[?2026h${'codex redraw '.repeat(8_000)}`) - - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - `[c\x1b[?2026h${'codex redraw '.repeat(8_000)}`, - expect.any(Function) - ) - - binding.dispose() - }) - - it('flushes pending hidden Codex query prefixes when the pane becomes visible', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const isVisibleRef = { current: false } - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef, - startup: { command: 'codex' } - }) as never - ) - try { - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b') - isVisibleRef.current = true - capturedDataCallback.current?.('[c') - - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) - } finally { - binding.dispose() - } - }) - - it('keeps split hidden-to-visible Codex stateful queries behind snapshot restore', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'snapshot-before-cpr\r\n', - cols: 100, - rows: 30 - }) - - const isVisibleRef = { current: false } - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef, - startup: { command: 'codex' } - }) as never - ) - try { - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[6') - isVisibleRef.current = true - capturedDataCallback.current?.('n') - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - 'snapshot-before-cpr\r\n', - expect.any(Function) - ) - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[6n', expect.any(Function)) - } finally { - binding.dispose() - } - }) - - it('preserves live agent cursor and focus modes on hidden-to-visible snapshot restore', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'agent-frame\x1b[?25l', - cols: 100, - rows: 30, - alternateScreen: true - }) - const paneKey = makePaneKey('tab-1', LEAF_1) - const now = Date.now() - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: '', - agentType: 'codex', - paneKey, - terminalTitle: 'codex', - updatedAt: now, - stateStartedAt: now, - stateHistory: [] - } - - const isVisibleRef = { current: false } - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef, - startup: { command: 'codex' } - }) as never - ) - try { - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[6') - isVisibleRef.current = true - capturedDataCallback.current?.('n') - await flushAsyncTicks(20) - - // A live agent owns ?1004h (focus reporting); the plain reset's ?1004l would silence focus events until restart, since agents only enable it at startup. - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), - expect.any(Function) - ) - } finally { - binding.dispose() - } - }) - - it('lets fresh host shell proof outrank stale live-agent metadata on reveal', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'stale-agent-frame\x1b[?25l', - cols: 100, - rows: 30, - alternateScreen: true, - terminalOwner: 'shell' - }) - const paneKey = makePaneKey('tab-1', LEAF_1) - const now = Date.now() - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: '', - agentType: 'codex', - paneKey, - terminalTitle: 'codex', - updatedAt: now, - stateStartedAt: now, - stateHistory: [] - } - - const isVisibleRef = { current: false } - const pane = createPane(1) - const binding = connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ isVisibleRef, startup: { command: 'codex' } }) as never - ) - try { - await flushAsyncTicks(6) - capturedDataCallback.current?.('\x1b[6') - isVisibleRef.current = true - capturedDataCallback.current?.('n') - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET), - expect.any(Function) - ) - } finally { - binding.dispose() - } - }) - - it('preserves current behavior when a snapshot has no host ownership proof', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'shell-frame\x1b[?25l', - cols: 100, - rows: 30, - alternateScreen: false - }) - - const isVisibleRef = { current: false } - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef, - startup: { command: 'codex' } - }) as never - ) - try { - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[6') - isVisibleRef.current = true - capturedDataCallback.current?.('n') - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_REATTACH_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), - expect.any(Function) - ) - } finally { - binding.dispose() - } - }) - - it('keeps a live alternate-screen pane interactive when a legacy snapshot omits its mode flag', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'legacy-tui-frame', - cols: 100, - rows: 30 - }) - - const isVisibleRef = { current: false } - const pane = createPane(1) - pane.terminal.buffer.active.type = 'alternate' - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef, - startup: { command: 'codex' } - }) as never - ) - try { - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[6') - isVisibleRef.current = true - capturedDataCallback.current?.('n') - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_REATTACH_RESET), - expect.any(Function) - ) - } finally { - binding.dispose() - } - }) - - it('grounds a stale alternate-screen snapshot when the host proves shell ownership', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'stale-tui-frame', - cols: 100, - rows: 30, - alternateScreen: true, - terminalOwner: 'shell' - }) - - const isVisibleRef = { current: false } - const pane = createPane(1) - pane.terminal.buffer.active.type = 'alternate' - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ isVisibleRef, startup: { command: 'codex' } }) as never - ) - try { - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[6') - isVisibleRef.current = true - capturedDataCallback.current?.('n') - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET), - expect.any(Function) - ) - expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() - } finally { - binding.dispose() - } - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.spec.ts new file mode 100644 index 00000000000..f957375788b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.spec.ts @@ -0,0 +1,627 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +/** What remountTerminalTabForRecovery answers now that it reports admission. */ +const REMOUNTED = { remounted: true as const, generation: 1 } +const TAB_MISSING = { remounted: false as const, declinedBy: 'tab-missing' as const } +const AUTOMATIC_REQUEST = expect.objectContaining({ trigger: 'automatic' }) + +export function registerPtyConnectionHiddenDeliveryGateTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + describe('hidden-delivery gate', () => { + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } as StoreState['settings'] + } + + function getSetHiddenRendererPtyMock(): ReturnType { + return window.api.pty.setHiddenRendererPty as unknown as ReturnType + } + + async function connectHiddenPane(deps: ReturnType): Promise<{ + transport: MockTransport + pane: ReturnType + dataCallback: ( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void + binding: { syncProcessTracking: () => void; dispose: () => void } + }> { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty(pane as never, manager as never, deps as never) as { + syncProcessTracking: () => void + dispose: () => void + } + await flushAsyncTicks(6) + expect(capturedDataCallback.current).not.toBeNull() + return { transport, pane, dataCallback: capturedDataCallback.current!, binding } + } + + // STA-4042 root: the restore-needed marker is the ONE point where "bytes + // were dropped" is known. The emulator carries state across chunks just like + // the cross-chunk parser the handler already resets, so the gap is closed + // here rather than left to each recovery path to remember. + it('closes the emulator state gap when a drop is announced', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + pane.terminal.write.mockClear() + + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: 64 + }) + await flushAsyncTicks(4) + + const written = pane.terminal.write.mock.calls.map(([data]) => data as string) + const gapReset = written.find((data) => data === RESET_AFTER_BYTE_GAP) + expect(gapReset).toBeDefined() + }) + + it('marks the PTY hidden on hidden output and clears it before requesting restore on reveal', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'model snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + pane.terminal.options.scrollback = 50_000 + + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) + + // Why: with the skip grammar gone, gated drops latch the restore via main's out-of-band marker, not a renderer content scan. + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: 64 + }) + + // Reveal rides the visible-resume backlog recovery hook. + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 50_000 }) + // The unhide IPC must precede the snapshot request (seq-guard contract). + const unhideOrder = setHiddenRendererPty.mock.invocationCallOrder.at(-1)! + const snapshotOrder = getMainBufferSnapshot.mock.invocationCallOrder[0]! + expect(unhideOrder).toBeLessThan(snapshotOrder) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('model snapshot'), + expect.any(Function) + ) + }) + + it('holds post-snapshot live bytes until a hidden restore can fit the destination grid', async () => { + enableMainAuthority() + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, transport, dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'source-grid hidden snapshot\r\n', + cols: 80, + rows: 24, + seq: 64 + }) + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + transport.resize.mockClear() + + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: 64 + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(transport.resize).not.toHaveBeenCalled() + dataCallback('live-after-hidden', { seq: 81, rawLength: 17 }) + await flushAsyncTicks(6) + expect( + pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + ).not.toContain('live-after-hidden') + + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(20) + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).toContain( + 'live-after-hidden' + ) + }) + + it('kicks pane recovery when reveal finds the write pipeline certified dead', async () => { + // 2026-07-13 fossil-pane incident: bytes drop while hidden, pipeline certified dead, cert recovery empty — reveal must re-kick it. + enableMainAuthority() + const remountTerminalTabForRecovery = vi.fn(() => REMOUNTED) + mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState + const { _resetTerminalPaneRecoveryForTests } = await import('./terminal-pane-recovery') + _resetTerminalPaneRecoveryForTests() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'model snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + + // Pipeline dies while hidden; certification-time recovery finds no remountable tab (budget unconsumed, no retry timer). + remountTerminalTabForRecovery.mockReturnValueOnce(TAB_MISSING as never) + const ackCredit = vi.fn() + const { writeTerminalOutput } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + writeTerminalOutput(pane.terminal, 'queued before certification', { + foreground: false, + ackCredit + }) + const { notifyUndeliverableWrite } = + await import('@/lib/pane-manager/terminal-write-pipeline-health') + notifyUndeliverableWrite(pane.terminal, 'write-stalled') + expect(ackCredit).toHaveBeenCalledTimes(1) + await flushAsyncTicks(4) + expect(remountTerminalTabForRecovery).toHaveBeenCalledTimes(1) + + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: 64 + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + // Restore stays skipped (a dead pipeline can't parse the snapshot), but recovery got exactly one re-kick. + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + expect(remountTerminalTabForRecovery).toHaveBeenCalledTimes(2) + expect(remountTerminalTabForRecovery).toHaveBeenLastCalledWith('tab-1', AUTOMATIC_REQUEST) + + // Latched per xterm instance: repeat restore attempts do not spam. + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: 96 + }) + await flushAsyncTicks(4) + expect(remountTerminalTabForRecovery).toHaveBeenCalledTimes(2) + _resetTerminalPaneRecoveryForTests() + }) + + it('clears the hidden bit on visibility flips through syncProcessTracking', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { dataCallback, binding } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + + // Hiding again re-marks through the same lifecycle hook. + ;(deps.isVisibleRef as { current: boolean }).current = false + binding.syncProcessTracking() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + }) + + it('marks hidden codex panes immediately — no startup renderer-query window remains', async () => { + enableMainAuthority() + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + // Why: Phase 6 deleted the 10s codex window — codex startups gate like any hidden pane; main answers their startup probes. + dataCallback('startup probe output\r\n') + expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) + + // The fact records the subscription for gate-managed PTYs; it never answers it (#9993). + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 8, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) + }) + + it('latches model restore from the out-of-band marker and restores on reveal', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'dropped bytes snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + // Why: the marker subscription is keyed by the live PTY id — the byte path latches it on the first hidden chunk. + dataCallback('pre-drop output\r\n', { seq: 16, rawLength: 17 }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + + // Main dropped gated bytes and signalled it out-of-band. + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: 64 + }) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('dropped bytes snapshot'), + expect.any(Function) + ) + }) + + it('never answers a 2031-subscribe fact, hidden or visible', async () => { + // Regression pin (#9993): a subscribe arms future notifications, it is not a query. + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport } = await connectHiddenPane(deps) + // Simulate spawn completion so the pane registers its fact consumer (mock transport never calls onPtySpawn). + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 24, + facts: [{ kind: '2031-subscribe' }] + }) + + expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) + expect(transport.sendInputImmediate).not.toHaveBeenCalledWith( + expect.stringMatching(/\?997/) + ) + }) + + it('registers the fact-observed 2031 subscription for later theme flips', async () => { + enableMainAuthority() + const recordPaneMode2031Subscription = vi.fn() + const deps = createDeps({ + isVisibleRef: { current: false }, + recordPaneMode2031Subscription + }) + const { transport } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + + // Why: without the registry write, maybePushMode2031Flip won't push CSI 997 after a theme change, so the TUI keeps a stale theme. + expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, 'dark') + expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) + }) + + it('retires the fact-registered subscription when the TUI withdraws it', async () => { + // The counterpart to the test above. A gated pane never sees the withdrawal bytes + // (main drops them) and the chunk scanner is disabled for it, so this fact is the only + // observer that can retire the subscription. Left registered, the next theme flip + // pushes CSI 997 at the shell that replaced the TUI. + enableMainAuthority() + const paneMode2031Ref = { current: new Map() } + const paneLastThemeModeRef = { current: new Map() } + const deps = createDeps({ + isVisibleRef: { current: false }, + paneMode2031Ref, + paneLastThemeModeRef, + // Exactly what use-terminal-pane-lifecycle wires up for this callback. + recordPaneMode2031Subscription: (paneId: number, subscribedMode: 'dark' | 'light') => { + paneMode2031Ref.current.set(paneId, true) + paneLastThemeModeRef.current.set(paneId, subscribedMode) + } + }) + await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(paneMode2031Ref.current.get(1)).toBe(true) + + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 24, + facts: [{ kind: '2031-unsubscribe' }] + }) + + expect(paneMode2031Ref.current.get(1)).toBeUndefined() + expect(paneLastThemeModeRef.current.get(1)).toBeUndefined() + }) + + it('leaves the chunk scanner silent on a gate-managed PTY', async () => { + // Main's '2031-subscribe' fact already records these; nothing on this path replies. + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport, dataCallback } = await connectHiddenPane(deps) + const before = transport.sendInput.mock.calls.length + + dataCallback('\x1b[?2031h') + + const replies = transport.sendInput.mock.calls + .slice(before) + .flat() + .filter((arg) => String(arg).includes('997')) + expect(replies).toEqual([]) + }) + + it('declares hidden-at-spawn on connect for hidden panes', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport } = await connectHiddenPane(deps) + // Why: mark the PTY hidden before its first byte, closing the spawn-time DA1-loss window where neither side replied. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ initiallyHidden: true }) + ) + }) + + it('keeps visible spawns undeclared (visible spawn unchanged)', async () => { + enableMainAuthority() + const deps = createDeps() + const { transport } = await connectHiddenPane(deps) + expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden') + }) + + it('declares hidden-at-spawn for hidden codex panes too', async () => { + enableMainAuthority() + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport } = await connectHiddenPane(deps) + // Why: the 10s codex startup window is gone — codex spawns are main-owned from byte zero (main pin: pty.test.ts DA1-from-model). + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ initiallyHidden: true }) + ) + }) + + it('does not gate or fact-reply when the hidden-delivery kill switch is off', async () => { + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } as StoreState['settings'] + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).not.toHaveBeenCalled() + + // Why: gate off keeps the byte-scan responder authoritative — the fact must not produce a second reply. + const factsHandler = await import('./terminal-side-effect-facts-handler') + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).not.toHaveBeenCalled() + }) + + it('clears a marked-hidden PTY on dispose so a remount is never gated', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { dataCallback, binding } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + + binding.dispose() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + }) + + it('never treats a live chunk that strips to empty as a restore marker', async () => { + // Why: an all-OSC-9999 chunk arrives as '' after stripping; only pty:modelRestoreNeeded may trigger a restore, not the empty byte path. + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + dataCallback('', { seq: 32, rawLength: 24 }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + }) + + it('gates marker re-arms during an in-flight foreground restore and repaints once after', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + getMainBufferSnapshot + .mockReturnValueOnce(firstSnapshot.promise) + .mockResolvedValue({ data: 'post-flood repaint\r\n', cols: 100, rows: 30, seq: 96 }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 64 + }) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + try { + // Why (rc.7.perf): a second drop marker while the first snapshot serializes is self-backpressure; must NOT re-fetch. + vi.useFakeTimers() + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 80 + }) + firstSnapshot.resolve({ data: 'first snapshot\r\n', cols: 100, rows: 30, seq: 64 }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + // Flood quiet: suppression window elapses and exactly ONE deferred repaint heals the dropped gap. + vi.advanceTimersByTime(2_100) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts deleted file mode 100644 index ff7c64900bc..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts +++ /dev/null @@ -1,682 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles' -import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' -import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -/** What remountTerminalTabForRecovery answers now that it reports admission. */ -const REMOUNTED = { remounted: true as const, generation: 1 } -const TAB_MISSING = { remounted: false as const, declinedBy: 'tab-missing' as const } -const AUTOMATIC_REQUEST = expect.objectContaining({ trigger: 'automatic' }) - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - describe('hidden-delivery gate', () => { - function enableMainAuthority(): void { - mockStoreState.settings = { - ...mockStoreState.settings, - terminalMainSideEffectAuthority: true - } as StoreState['settings'] - } - - function getSetHiddenRendererPtyMock(): ReturnType { - return window.api.pty.setHiddenRendererPty as unknown as ReturnType - } - - async function connectHiddenPane(deps: ReturnType): Promise<{ - transport: MockTransport - pane: ReturnType - dataCallback: ( - data: string, - meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } - ) => void - binding: { syncProcessTracking: () => void; dispose: () => void } - }> { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: - | (( - data: string, - meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } - ) => void) - | null - } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty(pane as never, manager as never, deps as never) as { - syncProcessTracking: () => void - dispose: () => void - } - await flushAsyncTicks(6) - expect(capturedDataCallback.current).not.toBeNull() - return { transport, pane, dataCallback: capturedDataCallback.current!, binding } - } - - // STA-4042 root: the restore-needed marker is the ONE point where "bytes - // were dropped" is known. The emulator carries state across chunks just like - // the cross-chunk parser the handler already resets, so the gap is closed - // here rather than left to each recovery path to remember. - it('closes the emulator state gap when a drop is announced', async () => { - enableMainAuthority() - const deps = createDeps({ isVisibleRef: { current: false } }) - const { pane, dataCallback } = await connectHiddenPane(deps) - dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) - pane.terminal.write.mockClear() - - const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') - _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) - await flushAsyncTicks(4) - - const written = pane.terminal.write.mock.calls.map(([data]) => data as string) - const gapReset = written.find((data) => data === RESET_AFTER_BYTE_GAP) - expect(gapReset).toBeDefined() - }) - - it('marks the PTY hidden on hidden output and clears it before requesting restore on reveal', async () => { - enableMainAuthority() - const deps = createDeps({ isVisibleRef: { current: false } }) - const { pane, dataCallback } = await connectHiddenPane(deps) - const setHiddenRendererPty = getSetHiddenRendererPtyMock() - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'model snapshot\r\n', - cols: 100, - rows: 30, - seq: 64 - }) - pane.terminal.options.scrollback = 50_000 - - dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) - expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) - - // Why: with the skip grammar gone, gated drops latch the restore via main's out-of-band marker, not a renderer content scan. - const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') - _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) - - // Reveal rides the visible-resume backlog recovery hook. - ;(deps.isVisibleRef as { current: boolean }).current = true - const { requestTerminalBacklogRecovery } = - await import('@/lib/pane-manager/pane-terminal-output-scheduler') - requestTerminalBacklogRecovery(pane.terminal as never) - await flushAsyncTicks(20) - - expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) - expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 50_000 }) - // The unhide IPC must precede the snapshot request (seq-guard contract). - const unhideOrder = setHiddenRendererPty.mock.invocationCallOrder.at(-1)! - const snapshotOrder = getMainBufferSnapshot.mock.invocationCallOrder[0]! - expect(unhideOrder).toBeLessThan(snapshotOrder) - expect(pane.terminal.write).toHaveBeenCalledWith( - expect.stringContaining('model snapshot'), - expect.any(Function) - ) - }) - - it('holds post-snapshot live bytes until a hidden restore can fit the destination grid', async () => { - enableMainAuthority() - const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') - const deps = createDeps({ isVisibleRef: { current: false } }) - const { pane, transport, dataCallback } = await connectHiddenPane(deps) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'source-grid hidden snapshot\r\n', - cols: 80, - rows: 24, - seq: 64 - }) - pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never - transport.resize.mockClear() - - dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) - const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') - _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) - ;(deps.isVisibleRef as { current: boolean }).current = true - const { requestTerminalBacklogRecovery } = - await import('@/lib/pane-manager/pane-terminal-output-scheduler') - requestTerminalBacklogRecovery(pane.terminal as never) - await flushAsyncTicks(20) - - expect(transport.resize).not.toHaveBeenCalled() - dataCallback('live-after-hidden', { seq: 81, rawLength: 17 }) - await flushAsyncTicks(6) - expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).not.toContain( - 'live-after-hidden' - ) - - pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never - safeFit(pane as never) - await flushAsyncTicks(20) - - expect(transport.resize).toHaveBeenCalledWith(120, 40) - expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).toContain( - 'live-after-hidden' - ) - }) - - it('kicks pane recovery when reveal finds the write pipeline certified dead', async () => { - // 2026-07-13 fossil-pane incident: bytes drop while hidden, pipeline certified dead, cert recovery empty — reveal must re-kick it. - enableMainAuthority() - const remountTerminalTabForRecovery = vi.fn(() => REMOUNTED) - mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState - const { _resetTerminalPaneRecoveryForTests } = await import('./terminal-pane-recovery') - _resetTerminalPaneRecoveryForTests() - const deps = createDeps({ isVisibleRef: { current: false } }) - const { pane, dataCallback } = await connectHiddenPane(deps) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'model snapshot\r\n', - cols: 100, - rows: 30, - seq: 64 - }) - - dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) - - // Pipeline dies while hidden; certification-time recovery finds no remountable tab (budget unconsumed, no retry timer). - remountTerminalTabForRecovery.mockReturnValueOnce(TAB_MISSING as never) - const ackCredit = vi.fn() - const { writeTerminalOutput } = - await import('@/lib/pane-manager/pane-terminal-output-scheduler') - writeTerminalOutput(pane.terminal, 'queued before certification', { - foreground: false, - ackCredit - }) - const { notifyUndeliverableWrite } = - await import('@/lib/pane-manager/terminal-write-pipeline-health') - notifyUndeliverableWrite(pane.terminal, 'write-stalled') - expect(ackCredit).toHaveBeenCalledTimes(1) - await flushAsyncTicks(4) - expect(remountTerminalTabForRecovery).toHaveBeenCalledTimes(1) - - const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') - _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) - ;(deps.isVisibleRef as { current: boolean }).current = true - const { requestTerminalBacklogRecovery } = - await import('@/lib/pane-manager/pane-terminal-output-scheduler') - requestTerminalBacklogRecovery(pane.terminal as never) - await flushAsyncTicks(20) - - // Restore stays skipped (a dead pipeline can't parse the snapshot), but recovery got exactly one re-kick. - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(remountTerminalTabForRecovery).toHaveBeenCalledTimes(2) - expect(remountTerminalTabForRecovery).toHaveBeenLastCalledWith('tab-1', AUTOMATIC_REQUEST) - - // Latched per xterm instance: repeat restore attempts do not spam. - _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 96 }) - await flushAsyncTicks(4) - expect(remountTerminalTabForRecovery).toHaveBeenCalledTimes(2) - _resetTerminalPaneRecoveryForTests() - }) - - it('clears the hidden bit on visibility flips through syncProcessTracking', async () => { - enableMainAuthority() - const deps = createDeps({ isVisibleRef: { current: false } }) - const { dataCallback, binding } = await connectHiddenPane(deps) - const setHiddenRendererPty = getSetHiddenRendererPtyMock() - - dataCallback('hidden output\r\n') - expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) - ;(deps.isVisibleRef as { current: boolean }).current = true - binding.syncProcessTracking() - expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) - - // Hiding again re-marks through the same lifecycle hook. - ;(deps.isVisibleRef as { current: boolean }).current = false - binding.syncProcessTracking() - expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) - }) - - it('marks hidden codex panes immediately — no startup renderer-query window remains', async () => { - enableMainAuthority() - const deps = createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) - const { transport, dataCallback } = await connectHiddenPane(deps) - const setHiddenRendererPty = getSetHiddenRendererPtyMock() - const transportOptions = createdTransportOptions.at(-1) as { - onPtySpawn?: (ptyId: string) => void - } - transportOptions.onPtySpawn?.('pty-id') - const factsHandler = await import('./terminal-side-effect-facts-handler') - - // Why: Phase 6 deleted the 10s codex window — codex startups gate like any hidden pane; main answers their startup probes. - dataCallback('startup probe output\r\n') - expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) - - // The fact records the subscription for gate-managed PTYs; it never answers it (#9993). - factsHandler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-id', - seq: 8, - facts: [{ kind: '2031-subscribe' }] - }) - expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - }) - - it('latches model restore from the out-of-band marker and restores on reveal', async () => { - enableMainAuthority() - const deps = createDeps({ isVisibleRef: { current: false } }) - const { pane, dataCallback } = await connectHiddenPane(deps) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'dropped bytes snapshot\r\n', - cols: 100, - rows: 30, - seq: 64 - }) - // Why: the marker subscription is keyed by the live PTY id — the byte path latches it on the first hidden chunk. - dataCallback('pre-drop output\r\n', { seq: 16, rawLength: 17 }) - const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') - - // Main dropped gated bytes and signalled it out-of-band. - _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - ;(deps.isVisibleRef as { current: boolean }).current = true - const { requestTerminalBacklogRecovery } = - await import('@/lib/pane-manager/pane-terminal-output-scheduler') - requestTerminalBacklogRecovery(pane.terminal as never) - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) - expect(pane.terminal.write).toHaveBeenCalledWith( - expect.stringContaining('dropped bytes snapshot'), - expect.any(Function) - ) - }) - - it('never answers a 2031-subscribe fact, hidden or visible', async () => { - // Regression pin (#9993): a subscribe arms future notifications, it is not a query. - enableMainAuthority() - const deps = createDeps({ isVisibleRef: { current: false } }) - const { transport } = await connectHiddenPane(deps) - // Simulate spawn completion so the pane registers its fact consumer (mock transport never calls onPtySpawn). - const transportOptions = createdTransportOptions.at(-1) as { - onPtySpawn?: (ptyId: string) => void - } - transportOptions.onPtySpawn?.('pty-id') - const factsHandler = await import('./terminal-side-effect-facts-handler') - - factsHandler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-id', - seq: 12, - facts: [{ kind: '2031-subscribe' }] - }) - ;(deps.isVisibleRef as { current: boolean }).current = true - factsHandler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-id', - seq: 24, - facts: [{ kind: '2031-subscribe' }] - }) - - expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - expect(transport.sendInputImmediate).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - }) - - it('registers the fact-observed 2031 subscription for later theme flips', async () => { - enableMainAuthority() - const recordPaneMode2031Subscription = vi.fn() - const deps = createDeps({ - isVisibleRef: { current: false }, - recordPaneMode2031Subscription - }) - const { transport } = await connectHiddenPane(deps) - const transportOptions = createdTransportOptions.at(-1) as { - onPtySpawn?: (ptyId: string) => void - } - transportOptions.onPtySpawn?.('pty-id') - const factsHandler = await import('./terminal-side-effect-facts-handler') - - factsHandler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-id', - seq: 12, - facts: [{ kind: '2031-subscribe' }] - }) - - // Why: without the registry write, maybePushMode2031Flip won't push CSI 997 after a theme change, so the TUI keeps a stale theme. - expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, 'dark') - expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - }) - - it('retires the fact-registered subscription when the TUI withdraws it', async () => { - // The counterpart to the test above. A gated pane never sees the withdrawal bytes - // (main drops them) and the chunk scanner is disabled for it, so this fact is the only - // observer that can retire the subscription. Left registered, the next theme flip - // pushes CSI 997 at the shell that replaced the TUI. - enableMainAuthority() - const paneMode2031Ref = { current: new Map() } - const paneLastThemeModeRef = { current: new Map() } - const deps = createDeps({ - isVisibleRef: { current: false }, - paneMode2031Ref, - paneLastThemeModeRef, - // Exactly what use-terminal-pane-lifecycle wires up for this callback. - recordPaneMode2031Subscription: (paneId: number, subscribedMode: 'dark' | 'light') => { - paneMode2031Ref.current.set(paneId, true) - paneLastThemeModeRef.current.set(paneId, subscribedMode) - } - }) - await connectHiddenPane(deps) - const transportOptions = createdTransportOptions.at(-1) as { - onPtySpawn?: (ptyId: string) => void - } - transportOptions.onPtySpawn?.('pty-id') - const factsHandler = await import('./terminal-side-effect-facts-handler') - factsHandler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-id', - seq: 12, - facts: [{ kind: '2031-subscribe' }] - }) - expect(paneMode2031Ref.current.get(1)).toBe(true) - - factsHandler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-id', - seq: 24, - facts: [{ kind: '2031-unsubscribe' }] - }) - - expect(paneMode2031Ref.current.get(1)).toBeUndefined() - expect(paneLastThemeModeRef.current.get(1)).toBeUndefined() - }) - - it('leaves the chunk scanner silent on a gate-managed PTY', async () => { - // Main's '2031-subscribe' fact already records these; nothing on this path replies. - enableMainAuthority() - const deps = createDeps({ isVisibleRef: { current: false } }) - const { transport, dataCallback } = await connectHiddenPane(deps) - const before = transport.sendInput.mock.calls.length - - dataCallback('\x1b[?2031h') - - const replies = transport.sendInput.mock.calls - .slice(before) - .flat() - .filter((arg) => String(arg).includes('997')) - expect(replies).toEqual([]) - }) - - it('declares hidden-at-spawn on connect for hidden panes', async () => { - enableMainAuthority() - const deps = createDeps({ isVisibleRef: { current: false } }) - const { transport } = await connectHiddenPane(deps) - // Why: mark the PTY hidden before its first byte, closing the spawn-time DA1-loss window where neither side replied. - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ initiallyHidden: true }) - ) - }) - - it('keeps visible spawns undeclared (visible spawn unchanged)', async () => { - enableMainAuthority() - const deps = createDeps() - const { transport } = await connectHiddenPane(deps) - expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden') - }) - - it('declares hidden-at-spawn for hidden codex panes too', async () => { - enableMainAuthority() - const deps = createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) - const { transport } = await connectHiddenPane(deps) - // Why: the 10s codex startup window is gone — codex spawns are main-owned from byte zero (main pin: pty.test.ts DA1-from-model). - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ initiallyHidden: true }) - ) - }) - - it('does not gate or fact-reply when the hidden-delivery kill switch is off', async () => { - enableMainAuthority() - mockStoreState.settings = { - ...mockStoreState.settings, - terminalHiddenDeliveryGate: false - } as StoreState['settings'] - const deps = createDeps({ isVisibleRef: { current: false } }) - const { transport, dataCallback } = await connectHiddenPane(deps) - const transportOptions = createdTransportOptions.at(-1) as { - onPtySpawn?: (ptyId: string) => void - } - transportOptions.onPtySpawn?.('pty-id') - const setHiddenRendererPty = getSetHiddenRendererPtyMock() - - dataCallback('hidden output\r\n') - expect(setHiddenRendererPty).not.toHaveBeenCalled() - - // Why: gate off keeps the byte-scan responder authoritative — the fact must not produce a second reply. - const factsHandler = await import('./terminal-side-effect-facts-handler') - factsHandler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-id', - seq: 12, - facts: [{ kind: '2031-subscribe' }] - }) - expect(transport.sendInput).not.toHaveBeenCalled() - }) - - it('clears a marked-hidden PTY on dispose so a remount is never gated', async () => { - enableMainAuthority() - const deps = createDeps({ isVisibleRef: { current: false } }) - const { dataCallback, binding } = await connectHiddenPane(deps) - const setHiddenRendererPty = getSetHiddenRendererPtyMock() - - dataCallback('hidden output\r\n') - expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) - - binding.dispose() - expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) - }) - - it('never treats a live chunk that strips to empty as a restore marker', async () => { - // Why: an all-OSC-9999 chunk arrives as '' after stripping; only pty:modelRestoreNeeded may trigger a restore, not the empty byte path. - enableMainAuthority() - const deps = createDeps({ isVisibleRef: { current: true } }) - const { dataCallback } = await connectHiddenPane(deps) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - - dataCallback('', { seq: 32, rawLength: 24 }) - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - }) - - it('gates marker re-arms during an in-flight foreground restore and repaints once after', async () => { - enableMainAuthority() - const deps = createDeps({ isVisibleRef: { current: true } }) - await connectHiddenPane(deps) - const transportOptions = createdTransportOptions.at(-1) as { - onPtySpawn?: (ptyId: string) => void - } - transportOptions.onPtySpawn?.('pty-id') - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const firstSnapshot = createDeferred<{ - data: string - cols: number - rows: number - seq: number - }>() - getMainBufferSnapshot - .mockReturnValueOnce(firstSnapshot.promise) - .mockResolvedValue({ data: 'post-flood repaint\r\n', cols: 100, rows: 30, seq: 96 }) - const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') - - _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'pending-cap', markerSeq: 64 }) - await flushAsyncTicks(4) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - - try { - // Why (rc.7.perf): a second drop marker while the first snapshot serializes is self-backpressure; must NOT re-fetch. - vi.useFakeTimers() - _dispatchPtyModelRestoreNeededForTest({ - id: 'pty-id', - reason: 'pending-cap', - markerSeq: 80 - }) - firstSnapshot.resolve({ data: 'first snapshot\r\n', cols: 100, rows: 30, seq: 64 }) - await flushAsyncTicks(20) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - - // Flood quiet: suppression window elapses and exactly ONE deferred repaint heals the dropped gap. - vi.advanceTimersByTime(2_100) - await flushAsyncTicks(20) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.spec.ts new file mode 100644 index 00000000000..ea37977da89 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.spec.ts @@ -0,0 +1,581 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks, renderHeadlessBuffer } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionHiddenOutputRestoreTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('does not apply stale background Codex query chunks after hidden snapshot restore', async () => { + // Fire-all like a real event target: both the pane resync and stale-visibility trust handlers listen for visibilitychange. + const visibilityChangeListeners: (() => void)[] = [] + const visibilityChangeHandler = { + current: (): void => { + for (const listener of visibilityChangeListeners) { + listener() + } + } + } + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'visibilitychange') { + visibilityChangeListeners.push(listener as () => void) + } + }), + removeEventListener: vi.fn() + } as unknown as Document + + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + const hiddenFrame = '\r\x1b[2KWorking hidden' + const staleQueryFrame = '\r\x1b[2KWorking stale\x1b[6n' + const currentFrame = '\r\x1b[2KWorking current' + const staleSeq = hiddenFrame.length + staleQueryFrame.length + const currentSeq = staleSeq + currentFrame.length + // The main emulator already parsed every frame, so the snapshot covers the query frame still in flight on the pty:data channel. + getMainBufferSnapshot.mockResolvedValue({ + data: currentFrame, + cols: 80, + rows: 8, + seq: currentSeq + }) + + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 8 + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + visibilityChangeHandler.current?.() + await flushAsyncTicks(20) + + // The in-flight chunks arrive in channel order after the restore already rebuilt the buffer from the newer snapshot. + vi.useFakeTimers() + try { + capturedDataCallback.current?.(staleQueryFrame, { + seq: staleSeq, + rawLength: staleQueryFrame.length, + background: true + }) + capturedDataCallback.current?.(currentFrame, { + seq: currentSeq, + rawLength: currentFrame.length, + background: true + }) + vi.advanceTimersByTime(50) + await flushAsyncTicks(6) + } finally { + vi.useRealTimers() + } + + const rendererBuffer = await renderHeadlessBuffer(writes, 80, 8) + const referenceBuffer = await renderHeadlessBuffer( + [hiddenFrame, staleQueryFrame, currentFrame], + 80, + 8 + ) + + expect(writes).not.toContain(staleQueryFrame) + expect(rendererBuffer).toEqual(referenceBuffer) + binding.dispose() + }) + + it('restores hidden Codex output after a suppressed exit revives the same ptyId with a restarted seq counter', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + const hiddenFrame = '\r\x1b[2KWorking hidden' + const visibleFrame = '\r\x1b[2KWorking now' + const hiddenSeq = hiddenFrame.length + const visibleSeq = hiddenSeq + visibleFrame.length + // Why 3 extra bytes: the main emulator runs ahead of the renderer's received chunks, so the snapshot seq exceeds the channel seq. + const preExitSnapshotSeq = visibleSeq + 3 + getMainBufferSnapshot.mockResolvedValue({ + data: visibleFrame, + cols: 80, + rows: 8, + seq: preExitSnapshotSeq + }) + + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 8 + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' }, + consumeSuppressedPtyExit: vi.fn(() => true) + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hiddenFrame, { seq: hiddenSeq, rawLength: hiddenFrame.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleFrame, { + seq: visibleSeq, + rawLength: visibleFrame.length + }) + await flushAsyncTicks(20) + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + onPtyExit?.('pty-id') + + // Revived session: same ptyId, seq restarted — the revived chunk sits below the pre-exit snapshot seq; only the exit reset stops it being judged covered. + ;(deps.isVisibleRef as { current: boolean }).current = false + const revivedHiddenFrame = '\r\x1b[2KWorking revived' + capturedDataCallback.current?.(revivedHiddenFrame, { + seq: preExitSnapshotSeq - 1, + rawLength: revivedHiddenFrame.length + }) + + const revivedSnapshotFrame = '\r\x1b[2KREVIVED SNAPSHOT' + getMainBufferSnapshot.mockResolvedValue({ + data: revivedSnapshotFrame, + cols: 80, + rows: 8, + seq: preExitSnapshotSeq - 1 + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const revivedVisibleFrame = '\r\x1b[2Kafter revive' + capturedDataCallback.current?.(revivedVisibleFrame, { + seq: preExitSnapshotSeq - 1 + revivedVisibleFrame.length, + rawLength: revivedVisibleFrame.length + }) + await flushAsyncTicks(20) + + const written = writes.join('') + expect(written).toContain('REVIVED SNAPSHOT') + expect(written).toContain('after revive') + binding.dispose() + }) + + it('restores hidden Codex output when the pty seq counter restarts without an observed exit', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 8 + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: true }, + startup: { command: 'codex' } + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + const establishedFrame = '\r\x1b[2KWorking established' + capturedDataCallback.current?.(establishedFrame, { + seq: 50_000, + rawLength: establishedFrame.length + }) + await flushAsyncTicks(6) + + // Main lost the pty silently and the seq counter restarted; the channel seq regression must invalidate the old high-water mark, not cover the new stream. + ;(deps.isVisibleRef as { current: boolean }).current = false + const revivedHiddenFrame = '\r\x1b[2KWorking revived' + capturedDataCallback.current?.(revivedHiddenFrame, { + seq: revivedHiddenFrame.length, + rawLength: revivedHiddenFrame.length + }) + + const revivedSnapshotFrame = '\r\x1b[2KREVIVED SNAPSHOT2' + getMainBufferSnapshot.mockResolvedValue({ + data: revivedSnapshotFrame, + cols: 80, + rows: 8, + seq: revivedHiddenFrame.length + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const revivedVisibleFrame = '\r\x1b[2Kafter revive' + capturedDataCallback.current?.(revivedVisibleFrame, { + seq: revivedHiddenFrame.length + revivedVisibleFrame.length, + rawLength: revivedVisibleFrame.length + }) + await flushAsyncTicks(20) + + expect(writes.join('')).toContain('REVIVED SNAPSHOT2') + binding.dispose() + }) + + it('writes ordinary hidden output live instead of proactively restoring a snapshot', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'small-hidden-output\r\n' + const live = 'visible-after-hidden\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: `snapshot-with-${hidden}`, + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith(hidden) + expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + disposable.dispose() + }) + + it('pauses capable paired output while hidden and restores exactly on reveal', async () => { + const { connectPanePty } = await import('./pty-connection') + const remotePtyId = 'remote:env-1@@terminal-1' + const transport = createMockTransport(remotePtyId) + let callbacks: ConnectCallbacks = {} + transport.connect.mockImplementation(async (options: { callbacks?: ConnectCallbacks }) => { + callbacks = options.callbacks ?? {} + return remotePtyId + }) + transport.setOutputPaused = vi.fn((paused: boolean) => { + callbacks.onOutputPauseChanged?.(paused, true) + return true + }) + const hidden = 'paired hidden flood\r\n' + const snapshot = 'paired snapshot with hidden flood\r\n' + const live = 'paired visible marker\r\n' + transport.serializeBuffer = vi.fn().mockResolvedValue({ + data: snapshot, + cols: 100, + rows: 30, + seq: hidden.length, + source: 'headless' + }) + transportFactoryQueue.push(transport) + mockStoreState.repos = [ + { id: 'repo1', connectionId: null, displayName: 'orca', executionHostId: 'runtime:env-1' } + ] + mockStoreState.worktreesByRepo.repo1[0].runtimeOwnerEnvironmentId = 'env-1' + + const pane = createPane(1) + const manager = createManager(1) + const recordPaneMode2031Subscription = vi.fn() + const deps = createDeps({ + isVisibleRef: { current: false }, + recordPaneMode2031Subscription + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) as { + syncProcessTracking: () => void + dispose: () => void + } + await flushAsyncTicks(6) + + expect(transport.setOutputPaused).toHaveBeenLastCalledWith(true) + expect( + (transport.sendInput as unknown as (data: string) => boolean)('hidden input marker') + ).toBe(true) + expect(transport.sendInput).toHaveBeenCalledWith('hidden input marker') + callbacks.onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) + + const factsHandler = await import('./terminal-side-effect-facts-handler') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) + mockStoreState.ptyIdsByTabId = { 'tab-1': [remotePtyId] } + mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { + [LEAF_1]: remotePtyId + } + const visibleStatusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'working'; prompt: string; agentType: 'claude' }) => void) + | undefined + expect(visibleStatusHandler).toBeTypeOf('function') + visibleStatusHandler?.({ + state: 'working', + prompt: 'visible control', + agentType: 'claude' + }) + expect(mockStoreState.setAgentStatus).toHaveBeenCalledTimes(1) + mockStoreState.setAgentStatus.mockClear() + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: remotePtyId, + seq: hidden.length, + facts: [ + { + kind: 'agent-status', + payload: { state: 'working', prompt: 'paired task', agentType: 'claude' } + }, + { kind: 'title', normalizedTitle: 'remote working', rawTitle: 'remote working' }, + { kind: '2031-subscribe' } + ] + }) + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'remote working') + expect(mockStoreState.setAgentStatus).toHaveBeenCalledTimes(1) + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + makePaneKey('tab-1', LEAF_1), + { + state: 'working', + prompt: 'paired task', + agentType: 'claude', + // Why: the renderer parsed these OSC 9999 bytes itself for a remote-runtime pane, so it + // is the sequencing authority for the row (STA-4293). + observation: expect.objectContaining({ origin: 'osc', kind: 'snapshot' }) + }, + undefined, + undefined, + { connectionId: null } + ) + // Remote gated panes record the subscription from the fact and answer nothing (#9993). + expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) + expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, expect.any(String)) + deps.paneMode2031Ref.current.set(1, true) + deps.paneLastThemeModeRef.current.set(1, 'dark') + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: remotePtyId, + seq: hidden.length + 1, + facts: [{ kind: '2031-unsubscribe' }] + }) + expect(deps.paneMode2031Ref.current.has(1)).toBe(false) + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + await flushAsyncTicks(20) + + expect(transport.setOutputPaused).toHaveBeenLastCalledWith(false) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(transport.setOutputPaused.mock.invocationCallOrder.at(-1)).toBeLessThan( + transport.serializeBuffer.mock.invocationCallOrder[0] + ) + callbacks.onData?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + const countWritten = (marker: string): number => written.split(marker).length - 1 + expect(countWritten(snapshot)).toBe(1) + expect(countWritten(live)).toBe(1) + expect(countWritten(hidden)).toBe(0) + + binding.dispose() + expect(transport.setOutputPaused).toHaveBeenLastCalledWith(false) + deps.setRuntimePaneTitle.mockClear() + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: remotePtyId, + seq: hidden.length + live.length + 1, + facts: [{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }] + }) + expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() + }) + + it('locally gates hidden paired output when a legacy host cannot pause it', async () => { + const { connectPanePty } = await import('./pty-connection') + const remotePtyId = 'remote:legacy-env@@terminal-1' + const transport = createMockTransport(remotePtyId) + let callbacks: ConnectCallbacks = {} + transport.connect.mockImplementation(async (options: { callbacks?: ConnectCallbacks }) => { + callbacks = options.callbacks ?? {} + return remotePtyId + }) + transport.setOutputPaused = vi.fn((paused: boolean) => { + callbacks.onOutputPauseChanged?.(paused, false) + return false + }) + const hidden = 'legacy hidden flood\r\n' + const snapshot = 'legacy snapshot with hidden flood\r\n' + const live = 'legacy visible marker\r\n' + transport.serializeBuffer = vi.fn().mockResolvedValue({ + data: snapshot, + cols: 100, + rows: 30, + seq: hidden.length, + source: 'headless' + }) + transportFactoryQueue.push(transport) + mockStoreState.repos = [ + { + id: 'repo1', + connectionId: null, + displayName: 'orca', + executionHostId: 'runtime:legacy-env' + } + ] + mockStoreState.worktreesByRepo.repo1[0].runtimeOwnerEnvironmentId = 'legacy-env' + + const pane = createPane(1) + const deps = createDeps({ isVisibleRef: { current: false } }) + const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) as { + syncProcessTracking: () => void + dispose: () => void + } + await flushAsyncTicks(6) + + expect(transport.setOutputPaused).toHaveBeenLastCalledWith(true) + callbacks.onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + await flushAsyncTicks(20) + + expect(transport.setOutputPaused).toHaveBeenLastCalledWith(false) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + callbacks.onData?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + expect(written).toContain(snapshot) + expect(written).toContain(live) + expect(written).not.toContain(hidden) + binding.dispose() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts deleted file mode 100644 index c9160be5f90..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts +++ /dev/null @@ -1,665 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks, renderHeadlessBuffer } from './pty-connection-test-async' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('does not apply stale background Codex query chunks after hidden snapshot restore', async () => { - // Fire-all like a real event target: both the pane resync and stale-visibility trust handlers listen for visibilitychange. - const visibilityChangeListeners: (() => void)[] = [] - const visibilityChangeHandler = { - current: (): void => { - for (const listener of visibilityChangeListeners) { - listener() - } - } - } - ;(globalThis as { document?: Document }).document = { - visibilityState: 'visible', - addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { - if (type === 'visibilitychange') { - visibilityChangeListeners.push(listener as () => void) - } - }), - removeEventListener: vi.fn() - } as unknown as Document - - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: - | (( - data: string, - meta?: { seq?: number; rawLength?: number; background?: boolean } - ) => void) - | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - - const hiddenFrame = '\r\x1b[2KWorking hidden' - const staleQueryFrame = '\r\x1b[2KWorking stale\x1b[6n' - const currentFrame = '\r\x1b[2KWorking current' - const staleSeq = hiddenFrame.length + staleQueryFrame.length - const currentSeq = staleSeq + currentFrame.length - // The main emulator already parsed every frame, so the snapshot covers the query frame still in flight on the pty:data channel. - getMainBufferSnapshot.mockResolvedValue({ - data: currentFrame, - cols: 80, - rows: 8, - seq: currentSeq - }) - - const pane = createPane(1) - pane.terminal.cols = 80 - pane.terminal.rows = 8 - const writes: string[] = [] - pane.terminal.write = vi.fn((data: string, callback?: () => void) => { - writes.push(data) - callback?.() - }) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) - const binding = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hiddenFrame, { - seq: hiddenFrame.length, - rawLength: hiddenFrame.length - }) - ;(deps.isVisibleRef as { current: boolean }).current = true - visibilityChangeHandler.current?.() - await flushAsyncTicks(20) - - // The in-flight chunks arrive in channel order after the restore already rebuilt the buffer from the newer snapshot. - vi.useFakeTimers() - try { - capturedDataCallback.current?.(staleQueryFrame, { - seq: staleSeq, - rawLength: staleQueryFrame.length, - background: true - }) - capturedDataCallback.current?.(currentFrame, { - seq: currentSeq, - rawLength: currentFrame.length, - background: true - }) - vi.advanceTimersByTime(50) - await flushAsyncTicks(6) - } finally { - vi.useRealTimers() - } - - const rendererBuffer = await renderHeadlessBuffer(writes, 80, 8) - const referenceBuffer = await renderHeadlessBuffer( - [hiddenFrame, staleQueryFrame, currentFrame], - 80, - 8 - ) - - expect(writes).not.toContain(staleQueryFrame) - expect(rendererBuffer).toEqual(referenceBuffer) - binding.dispose() - }) - - it('restores hidden Codex output after a suppressed exit revives the same ptyId with a restarted seq counter', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: - | (( - data: string, - meta?: { seq?: number; rawLength?: number; background?: boolean } - ) => void) - | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - - const hiddenFrame = '\r\x1b[2KWorking hidden' - const visibleFrame = '\r\x1b[2KWorking now' - const hiddenSeq = hiddenFrame.length - const visibleSeq = hiddenSeq + visibleFrame.length - // Why 3 extra bytes: the main emulator runs ahead of the renderer's received chunks, so the snapshot seq exceeds the channel seq. - const preExitSnapshotSeq = visibleSeq + 3 - getMainBufferSnapshot.mockResolvedValue({ - data: visibleFrame, - cols: 80, - rows: 8, - seq: preExitSnapshotSeq - }) - - const pane = createPane(1) - pane.terminal.cols = 80 - pane.terminal.rows = 8 - const writes: string[] = [] - pane.terminal.write = vi.fn((data: string, callback?: () => void) => { - writes.push(data) - callback?.() - }) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' }, - consumeSuppressedPtyExit: vi.fn(() => true) - }) - const binding = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hiddenFrame, { seq: hiddenSeq, rawLength: hiddenFrame.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(visibleFrame, { - seq: visibleSeq, - rawLength: visibleFrame.length - }) - await flushAsyncTicks(20) - - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - expect(onPtyExit).toBeTypeOf('function') - onPtyExit?.('pty-id') - - // Revived session: same ptyId, seq restarted — the revived chunk sits below the pre-exit snapshot seq; only the exit reset stops it being judged covered. - ;(deps.isVisibleRef as { current: boolean }).current = false - const revivedHiddenFrame = '\r\x1b[2KWorking revived' - capturedDataCallback.current?.(revivedHiddenFrame, { - seq: preExitSnapshotSeq - 1, - rawLength: revivedHiddenFrame.length - }) - - const revivedSnapshotFrame = '\r\x1b[2KREVIVED SNAPSHOT' - getMainBufferSnapshot.mockResolvedValue({ - data: revivedSnapshotFrame, - cols: 80, - rows: 8, - seq: preExitSnapshotSeq - 1 - }) - ;(deps.isVisibleRef as { current: boolean }).current = true - const revivedVisibleFrame = '\r\x1b[2Kafter revive' - capturedDataCallback.current?.(revivedVisibleFrame, { - seq: preExitSnapshotSeq - 1 + revivedVisibleFrame.length, - rawLength: revivedVisibleFrame.length - }) - await flushAsyncTicks(20) - - const written = writes.join('') - expect(written).toContain('REVIVED SNAPSHOT') - expect(written).toContain('after revive') - binding.dispose() - }) - - it('restores hidden Codex output when the pty seq counter restarts without an observed exit', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: - | (( - data: string, - meta?: { seq?: number; rawLength?: number; background?: boolean } - ) => void) - | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - - const pane = createPane(1) - pane.terminal.cols = 80 - pane.terminal.rows = 8 - const writes: string[] = [] - pane.terminal.write = vi.fn((data: string, callback?: () => void) => { - writes.push(data) - callback?.() - }) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: true }, - startup: { command: 'codex' } - }) - const binding = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - const establishedFrame = '\r\x1b[2KWorking established' - capturedDataCallback.current?.(establishedFrame, { - seq: 50_000, - rawLength: establishedFrame.length - }) - await flushAsyncTicks(6) - - // Main lost the pty silently and the seq counter restarted; the channel seq regression must invalidate the old high-water mark, not cover the new stream. - ;(deps.isVisibleRef as { current: boolean }).current = false - const revivedHiddenFrame = '\r\x1b[2KWorking revived' - capturedDataCallback.current?.(revivedHiddenFrame, { - seq: revivedHiddenFrame.length, - rawLength: revivedHiddenFrame.length - }) - - const revivedSnapshotFrame = '\r\x1b[2KREVIVED SNAPSHOT2' - getMainBufferSnapshot.mockResolvedValue({ - data: revivedSnapshotFrame, - cols: 80, - rows: 8, - seq: revivedHiddenFrame.length - }) - ;(deps.isVisibleRef as { current: boolean }).current = true - const revivedVisibleFrame = '\r\x1b[2Kafter revive' - capturedDataCallback.current?.(revivedVisibleFrame, { - seq: revivedHiddenFrame.length + revivedVisibleFrame.length, - rawLength: revivedVisibleFrame.length - }) - await flushAsyncTicks(20) - - expect(writes.join('')).toContain('REVIVED SNAPSHOT2') - binding.dispose() - }) - - it('writes ordinary hidden output live instead of proactively restoring a snapshot', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const hidden = 'small-hidden-output\r\n' - const live = 'visible-after-hidden\r\n' - getMainBufferSnapshot.mockResolvedValue({ - data: `snapshot-with-${hidden}`, - cols: 100, - rows: 30, - seq: hidden.length + live.length - }) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) - - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(hidden) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) - disposable.dispose() - }) - - it('pauses capable paired output while hidden and restores exactly on reveal', async () => { - const { connectPanePty } = await import('./pty-connection') - const remotePtyId = 'remote:env-1@@terminal-1' - const transport = createMockTransport(remotePtyId) - let callbacks: ConnectCallbacks = {} - transport.connect.mockImplementation(async (options: { callbacks?: ConnectCallbacks }) => { - callbacks = options.callbacks ?? {} - return remotePtyId - }) - transport.setOutputPaused = vi.fn((paused: boolean) => { - callbacks.onOutputPauseChanged?.(paused, true) - return true - }) - const hidden = 'paired hidden flood\r\n' - const snapshot = 'paired snapshot with hidden flood\r\n' - const live = 'paired visible marker\r\n' - transport.serializeBuffer = vi.fn().mockResolvedValue({ - data: snapshot, - cols: 100, - rows: 30, - seq: hidden.length, - source: 'headless' - }) - transportFactoryQueue.push(transport) - mockStoreState.repos = [ - { id: 'repo1', connectionId: null, displayName: 'orca', executionHostId: 'runtime:env-1' } - ] - mockStoreState.worktreesByRepo.repo1[0].runtimeOwnerEnvironmentId = 'env-1' - - const pane = createPane(1) - const manager = createManager(1) - const recordPaneMode2031Subscription = vi.fn() - const deps = createDeps({ - isVisibleRef: { current: false }, - recordPaneMode2031Subscription - }) - const binding = connectPanePty(pane as never, manager as never, deps as never) as { - syncProcessTracking: () => void - dispose: () => void - } - await flushAsyncTicks(6) - - expect(transport.setOutputPaused).toHaveBeenLastCalledWith(true) - expect( - (transport.sendInput as unknown as (data: string) => boolean)('hidden input marker') - ).toBe(true) - expect(transport.sendInput).toHaveBeenCalledWith('hidden input marker') - callbacks.onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) - - const factsHandler = await import('./terminal-side-effect-facts-handler') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) - mockStoreState.ptyIdsByTabId = { 'tab-1': [remotePtyId] } - mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { - [LEAF_1]: remotePtyId - } - const visibleStatusHandler = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'working'; prompt: string; agentType: 'claude' }) => void) - | undefined - expect(visibleStatusHandler).toBeTypeOf('function') - visibleStatusHandler?.({ - state: 'working', - prompt: 'visible control', - agentType: 'claude' - }) - expect(mockStoreState.setAgentStatus).toHaveBeenCalledTimes(1) - mockStoreState.setAgentStatus.mockClear() - factsHandler._dispatchTerminalSideEffectBatchForTest({ - ptyId: remotePtyId, - seq: hidden.length, - facts: [ - { - kind: 'agent-status', - payload: { state: 'working', prompt: 'paired task', agentType: 'claude' } - }, - { kind: 'title', normalizedTitle: 'remote working', rawTitle: 'remote working' }, - { kind: '2031-subscribe' } - ] - }) - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'remote working') - expect(mockStoreState.setAgentStatus).toHaveBeenCalledTimes(1) - expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( - makePaneKey('tab-1', LEAF_1), - { - state: 'working', - prompt: 'paired task', - agentType: 'claude', - // Why: the renderer parsed these OSC 9999 bytes itself for a remote-runtime pane, so it - // is the sequencing authority for the row (STA-4293). - observation: expect.objectContaining({ origin: 'osc', kind: 'snapshot' }) - }, - undefined, - undefined, - { connectionId: null } - ) - // Remote gated panes record the subscription from the fact and answer nothing (#9993). - expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, expect.any(String)) - deps.paneMode2031Ref.current.set(1, true) - deps.paneLastThemeModeRef.current.set(1, 'dark') - factsHandler._dispatchTerminalSideEffectBatchForTest({ - ptyId: remotePtyId, - seq: hidden.length + 1, - facts: [{ kind: '2031-unsubscribe' }] - }) - expect(deps.paneMode2031Ref.current.has(1)).toBe(false) - - ;(deps.isVisibleRef as { current: boolean }).current = true - binding.syncProcessTracking() - await flushAsyncTicks(20) - - expect(transport.setOutputPaused).toHaveBeenLastCalledWith(false) - expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) - expect(transport.setOutputPaused.mock.invocationCallOrder.at(-1)).toBeLessThan( - transport.serializeBuffer.mock.invocationCallOrder[0] - ) - callbacks.onData?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') - const countWritten = (marker: string): number => written.split(marker).length - 1 - expect(countWritten(snapshot)).toBe(1) - expect(countWritten(live)).toBe(1) - expect(countWritten(hidden)).toBe(0) - - binding.dispose() - expect(transport.setOutputPaused).toHaveBeenLastCalledWith(false) - deps.setRuntimePaneTitle.mockClear() - factsHandler._dispatchTerminalSideEffectBatchForTest({ - ptyId: remotePtyId, - seq: hidden.length + live.length + 1, - facts: [{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }] - }) - expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() - }) - - it('locally gates hidden paired output when a legacy host cannot pause it', async () => { - const { connectPanePty } = await import('./pty-connection') - const remotePtyId = 'remote:legacy-env@@terminal-1' - const transport = createMockTransport(remotePtyId) - let callbacks: ConnectCallbacks = {} - transport.connect.mockImplementation(async (options: { callbacks?: ConnectCallbacks }) => { - callbacks = options.callbacks ?? {} - return remotePtyId - }) - transport.setOutputPaused = vi.fn((paused: boolean) => { - callbacks.onOutputPauseChanged?.(paused, false) - return false - }) - const hidden = 'legacy hidden flood\r\n' - const snapshot = 'legacy snapshot with hidden flood\r\n' - const live = 'legacy visible marker\r\n' - transport.serializeBuffer = vi.fn().mockResolvedValue({ - data: snapshot, - cols: 100, - rows: 30, - seq: hidden.length, - source: 'headless' - }) - transportFactoryQueue.push(transport) - mockStoreState.repos = [ - { - id: 'repo1', - connectionId: null, - displayName: 'orca', - executionHostId: 'runtime:legacy-env' - } - ] - mockStoreState.worktreesByRepo.repo1[0].runtimeOwnerEnvironmentId = 'legacy-env' - - const pane = createPane(1) - const deps = createDeps({ isVisibleRef: { current: false } }) - const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) as { - syncProcessTracking: () => void - dispose: () => void - } - await flushAsyncTicks(6) - - expect(transport.setOutputPaused).toHaveBeenLastCalledWith(true) - callbacks.onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) - - ;(deps.isVisibleRef as { current: boolean }).current = true - binding.syncProcessTracking() - await flushAsyncTicks(20) - - expect(transport.setOutputPaused).toHaveBeenLastCalledWith(false) - expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) - callbacks.onData?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - - const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') - expect(written).toContain(snapshot) - expect(written).toContain(live) - expect(written).not.toContain(hidden) - binding.dispose() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.spec.ts new file mode 100644 index 00000000000..02949478e21 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.spec.ts @@ -0,0 +1,447 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionHiddenQuerySnapshotRestoreTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('keeps all visible bytes after a pending hidden ESC becomes non-query output', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-before-visible\r\n', + cols: 100, + rows: 30 + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b') + isVisibleRef.current = true + capturedDataCallback.current?.('hello') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith('hello', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith('ello', expect.any(Function)) + } finally { + binding.dispose() + } + }) + + it('repaints from the main-owned snapshot when main drops pending output at the cap', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { droppedOutput?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'healed from snapshot\r\n', + cols: 100, + rows: 30 + }) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ isVisibleRef: { current: true } }) as never + ) + try { + await flushAsyncTicks(6) + getMainBufferSnapshot.mockClear() + + // Main hit the per-PTY pending cap and sent the droppedOutput sentinel: the stream has a gap, so repaint from the authoritative main-owned buffer. + capturedDataCallback.current?.('\x1b[?2031h', { droppedOutput: true }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalled() + expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('healed from snapshot'), + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('keeps split stateful Codex queries live after becoming visible', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-before-visible\r\n', + cols: 100, + rows: 30 + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[') + isVisibleRef.current = true + capturedDataCallback.current?.('6n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[6n', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith('6n', expect.any(Function)) + } finally { + binding.dispose() + } + }) + + it('drops pending hidden Codex query prefixes when the PTY changes', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b') + ;(transport.attach as unknown as (opts: { existingPtyId: string }) => void)({ + existingPtyId: 'pty-new' + }) + isVisibleRef.current = true + capturedDataCallback.current?.('[c') + + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith('[c', expect.any(Function)) + } finally { + binding.dispose() + } + }) + + it('does not live-render split hidden Codex non-query CSI output', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?202') + capturedDataCallback.current?.('6h') + capturedDataCallback.current?.('\x1b[?203') + capturedDataCallback.current?.('1h') + + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[?202', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith('6h', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[?203', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith('1h', expect.any(Function)) + + binding.dispose() + }) + + it('answers split hidden Codex OSC color queries directly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b]11;?') + capturedDataCallback.current?.(`\x1b\\\x1b[?2026h${'codex redraw '.repeat(8_000)}`) + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + `\x1b\\\x1b[?2026h${'codex redraw '.repeat(8_000)}`, + expect.any(Function) + ) + + binding.dispose() + }) + + it('answers hidden Codex OSC color queries split before the prefix directly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b]') + capturedDataCallback.current?.(`11;?\x1b\\\x1b[?2026h${'codex redraw '.repeat(8_000)}`) + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + `11;?\x1b\\\x1b[?2026h${'codex redraw '.repeat(8_000)}`, + expect.any(Function) + ) + + binding.dispose() + }) + + it('keeps later hidden Codex stateless terminal queries on the live xterm path', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + vi.advanceTimersByTime(30_000) + capturedDataCallback.current?.('\x1b[5n') + vi.advanceTimersByTime(50) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[5n', expect.any(Function)) + } finally { + vi.useRealTimers() + } + + binding.dispose() + }) + + it('keeps clean hidden Codex stateful cursor-position queries on the live xterm path', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[10;20H\x1b[6n') + + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[10;20H\x1b[6n', expect.any(Function)) + + binding.dispose() + }) + + it('does not answer dirty hidden Codex stateful queries from stale xterm state', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(`\x1b[2J\x1b[H${'codex redraw '.repeat(8_000)}`) + capturedDataCallback.current?.('\x1b[6n') + + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[6n', expect.any(Function)) + + binding.dispose() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.test.ts deleted file mode 100644 index 36a7610ccfd..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.test.ts +++ /dev/null @@ -1,518 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('keeps all visible bytes after a pending hidden ESC becomes non-query output', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'snapshot-before-visible\r\n', - cols: 100, - rows: 30 - }) - - const isVisibleRef = { current: false } - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef, - startup: { command: 'codex' } - }) as never - ) - try { - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b') - isVisibleRef.current = true - capturedDataCallback.current?.('hello') - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith('hello', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith('ello', expect.any(Function)) - } finally { - binding.dispose() - } - }) - - it('repaints from the main-owned snapshot when main drops pending output at the cap', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { droppedOutput?: boolean }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'healed from snapshot\r\n', - cols: 100, - rows: 30 - }) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ isVisibleRef: { current: true } }) as never - ) - try { - await flushAsyncTicks(6) - getMainBufferSnapshot.mockClear() - - // Main hit the per-PTY pending cap and sent the droppedOutput sentinel: the stream has a gap, so repaint from the authoritative main-owned buffer. - capturedDataCallback.current?.('\x1b[?2031h', { droppedOutput: true }) - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).toHaveBeenCalled() - expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - expect(pane.terminal.write).toHaveBeenCalledWith( - expect.stringContaining('healed from snapshot'), - expect.any(Function) - ) - } finally { - binding.dispose() - } - }) - - it('keeps split stateful Codex queries live after becoming visible', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue({ - data: 'snapshot-before-visible\r\n', - cols: 100, - rows: 30 - }) - - const isVisibleRef = { current: false } - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef, - startup: { command: 'codex' } - }) as never - ) - try { - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[') - isVisibleRef.current = true - capturedDataCallback.current?.('6n') - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[6n', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith('6n', expect.any(Function)) - } finally { - binding.dispose() - } - }) - - it('drops pending hidden Codex query prefixes when the PTY changes', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const isVisibleRef = { current: false } - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef, - startup: { command: 'codex' } - }) as never - ) - try { - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b') - ;(transport.attach as unknown as (opts: { existingPtyId: string }) => void)({ - existingPtyId: 'pty-new' - }) - isVisibleRef.current = true - capturedDataCallback.current?.('[c') - - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b', expect.any(Function)) - expect(pane.terminal.write).toHaveBeenCalledWith('[c', expect.any(Function)) - } finally { - binding.dispose() - } - }) - - it('does not live-render split hidden Codex non-query CSI output', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[?202') - capturedDataCallback.current?.('6h') - capturedDataCallback.current?.('\x1b[?203') - capturedDataCallback.current?.('1h') - - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[?202', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith('6h', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[?203', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith('1h', expect.any(Function)) - - binding.dispose() - }) - - it('answers split hidden Codex OSC color queries directly', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b]11;?') - capturedDataCallback.current?.(`\x1b\\\x1b[?2026h${'codex redraw '.repeat(8_000)}`) - - expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - `\x1b\\\x1b[?2026h${'codex redraw '.repeat(8_000)}`, - expect.any(Function) - ) - - binding.dispose() - }) - - it('answers hidden Codex OSC color queries split before the prefix directly', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b]') - capturedDataCallback.current?.(`11;?\x1b\\\x1b[?2026h${'codex redraw '.repeat(8_000)}`) - - expect(transport.sendInput).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - `11;?\x1b\\\x1b[?2026h${'codex redraw '.repeat(8_000)}`, - expect.any(Function) - ) - - binding.dispose() - }) - - it('keeps later hidden Codex stateless terminal queries on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - vi.useFakeTimers() - try { - vi.advanceTimersByTime(30_000) - capturedDataCallback.current?.('\x1b[5n') - vi.advanceTimersByTime(50) - - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[5n', expect.any(Function)) - } finally { - vi.useRealTimers() - } - - binding.dispose() - }) - - it('keeps clean hidden Codex stateful cursor-position queries on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[10;20H\x1b[6n') - - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[10;20H\x1b[6n', expect.any(Function)) - - binding.dispose() - }) - - it('does not answer dirty hidden Codex stateful queries from stale xterm state', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(`\x1b[2J\x1b[H${'codex redraw '.repeat(8_000)}`) - capturedDataCallback.current?.('\x1b[6n') - - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[6n', expect.any(Function)) - - binding.dispose() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts new file mode 100644 index 00000000000..ad9d2cd240f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts @@ -0,0 +1,40 @@ +import { describe } from 'vitest' +import { registerPtyConnectionHiddenAtlasRecoveryTests } from './pty-connection-hidden-atlas-recovery.spec' +import { registerPtyConnectionHiddenBacklogReconciliationTests } from './pty-connection-hidden-backlog-reconciliation.spec' +import { registerPtyConnectionHiddenOutputRestoreTests } from './pty-connection-hidden-output-restore.spec' +import { registerPtyConnectionHiddenQuerySnapshotRestoreTests } from './pty-connection-hidden-query-snapshot-restore.spec' +import { registerPtyConnectionHiddenSnapshotLiveOverlapTests } from './pty-connection-hidden-snapshot-live-overlap.spec' +import { registerPtyConnectionHiddenSnapshotResizeSignalsTests } from './pty-connection-hidden-snapshot-resize-signals.spec' +import { registerPtyConnectionHiddenTuiSnapshotReplayTests } from './pty-connection-hidden-tui-snapshot-replay.spec' +import { registerPtyConnectionStalledHiddenRestoreTests } from './pty-connection-stalled-hidden-restore.spec' +import { registerPtyConnectionDaemonSnapshotReplayTests } from './pty-connection-daemon-snapshot-replay.spec' +import { registerPtyConnectionDeferredReattachLiveOutputTests } from './pty-connection-deferred-reattach-live-output.spec' + +describe('pty-connection-hidden-atlas-recovery', registerPtyConnectionHiddenAtlasRecoveryTests) +describe( + 'pty-connection-hidden-backlog-reconciliation', + registerPtyConnectionHiddenBacklogReconciliationTests +) +describe('pty-connection-hidden-output-restore', registerPtyConnectionHiddenOutputRestoreTests) +describe( + 'pty-connection-hidden-query-snapshot-restore', + registerPtyConnectionHiddenQuerySnapshotRestoreTests +) +describe( + 'pty-connection-hidden-snapshot-live-overlap', + registerPtyConnectionHiddenSnapshotLiveOverlapTests +) +describe( + 'pty-connection-hidden-snapshot-resize-signals', + registerPtyConnectionHiddenSnapshotResizeSignalsTests +) +describe( + 'pty-connection-hidden-tui-snapshot-replay', + registerPtyConnectionHiddenTuiSnapshotReplayTests +) +describe('pty-connection-stalled-hidden-restore', registerPtyConnectionStalledHiddenRestoreTests) +describe('pty-connection-daemon-snapshot-replay', registerPtyConnectionDaemonSnapshotReplayTests) +describe( + 'pty-connection-deferred-reattach-live-output', + registerPtyConnectionDeferredReattachLiveOutputTests +) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.spec.ts new file mode 100644 index 00000000000..56264ea2798 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.spec.ts @@ -0,0 +1,213 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionHiddenSnapshotLiveOverlapTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('writes foreground chunks that are newer than the restored main snapshot', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'new-live-output\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-before-live\r\n', + cols: 120, + rows: 40, + seq: hidden.length + }) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + 'snapshot-before-live\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + disposable.dispose() + }) + + it('writes only the live chunk suffix not covered by the restored main snapshot', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const livePrefix = 'covered-by-snapshot:' + const liveSuffix = 'still-new\r\n' + const live = livePrefix + liveSuffix + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-through-live-prefix\r\n', + cols: 120, + rows: 40, + seq: hidden.length + livePrefix.length + }) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenCalledWith( + 'snapshot-through-live-prefix\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith(liveSuffix, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) + disposable.dispose() + }) + + it('re-snapshots instead of duplicating partially overlapped chunks with stripped OSC bytes', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const cleanedLive = 'clean-visible-output\r\n' + const rawLiveLength = cleanedLive.length + 32 + getMainBufferSnapshot + .mockResolvedValueOnce({ + data: 'snapshot-splits-osc-live\r\n', + cols: 120, + rows: 40, + seq: hidden.length + 4 + }) + .mockResolvedValueOnce({ + data: 'snapshot-after-osc-live\r\n', + cols: 120, + rows: 40, + seq: hidden.length + rawLiveLength + }) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(cleanedLive, { + seq: hidden.length + rawLiveLength, + rawLength: rawLiveLength + }) + await flushAsyncTicks(30) + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(pane.terminal.write).toHaveBeenCalledWith( + 'snapshot-after-osc-live\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith(cleanedLive, expect.any(Function)) + disposable.dispose() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts deleted file mode 100644 index a85f0e79562..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('writes foreground chunks that are newer than the restored main snapshot', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const live = 'new-live-output\r\n' - getMainBufferSnapshot.mockResolvedValue({ - data: 'snapshot-before-live\r\n', - cols: 120, - rows: 40, - seq: hidden.length - }) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - 'snapshot-before-live\r\n', - expect.any(Function) - ) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) - disposable.dispose() - }) - - it('writes only the live chunk suffix not covered by the restored main snapshot', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const livePrefix = 'covered-by-snapshot:' - const liveSuffix = 'still-new\r\n' - const live = livePrefix + liveSuffix - getMainBufferSnapshot.mockResolvedValue({ - data: 'snapshot-through-live-prefix\r\n', - cols: 120, - rows: 40, - seq: hidden.length + livePrefix.length - }) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - expect(pane.terminal.write).toHaveBeenCalledWith( - 'snapshot-through-live-prefix\r\n', - expect.any(Function) - ) - expect(pane.terminal.write).toHaveBeenCalledWith(liveSuffix, expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) - disposable.dispose() - }) - - it('re-snapshots instead of duplicating partially overlapped chunks with stripped OSC bytes', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const cleanedLive = 'clean-visible-output\r\n' - const rawLiveLength = cleanedLive.length + 32 - getMainBufferSnapshot - .mockResolvedValueOnce({ - data: 'snapshot-splits-osc-live\r\n', - cols: 120, - rows: 40, - seq: hidden.length + 4 - }) - .mockResolvedValueOnce({ - data: 'snapshot-after-osc-live\r\n', - cols: 120, - rows: 40, - seq: hidden.length + rawLiveLength - }) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(cleanedLive, { - seq: hidden.length + rawLiveLength, - rawLength: rawLiveLength - }) - await flushAsyncTicks(30) - - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) - expect(pane.terminal.write).toHaveBeenCalledWith( - 'snapshot-after-osc-live\r\n', - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith(cleanedLive, expect.any(Function)) - disposable.dispose() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.spec.ts new file mode 100644 index 00000000000..2eca41c458a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.spec.ts @@ -0,0 +1,442 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { + LEAF_1, + captureCallbackTerminalWrites, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionHiddenSnapshotResizeSignalsTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('keeps recovery pending when hidden output arrives during an in-flight snapshot', async () => { + let visibilityState: DocumentVisibilityState = 'visible' + // Fire-all like a real event target: both the pane resync and stale-visibility trust handlers listen for visibilitychange. + const visibilityChangeListeners: (() => void)[] = [] + const visibilityChangeHandler = { + current: (): void => { + for (const listener of visibilityChangeListeners) { + listener() + } + } + } + ;(globalThis as { document?: Document }).document = { + get visibilityState() { + return visibilityState + }, + addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'visibilitychange') { + visibilityChangeListeners.push(listener as () => void) + } + }), + removeEventListener: vi.fn() + } as unknown as Document + + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const signalPty = window.api.pty.signal as unknown as ReturnType + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const visibleLive = 'visible-before-hide\r\n' + const hiddenAgain = 'hidden-during-restore\r\n' + getMainBufferSnapshot.mockReturnValueOnce(firstSnapshot.promise).mockResolvedValueOnce({ + data: 'snapshot-after-hidden-again\r\n', + cols: 120, + rows: 40, + seq: hidden.length + visibleLive.length + hiddenAgain.length + }) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + visibilityState = 'visible' + capturedDataCallback.current?.(visibleLive, { + seq: hidden.length + visibleLive.length, + rawLength: visibleLive.length + }) + await flushAsyncTicks(2) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + ;(deps.isVisibleRef as { current: boolean }).current = false + visibilityState = 'hidden' + capturedDataCallback.current?.(hiddenAgain, { + seq: hidden.length + visibleLive.length + hiddenAgain.length, + rawLength: hiddenAgain.length + }) + transport.resize.mockClear() + signalPty.mockClear() + firstSnapshot.resolve({ + data: 'snapshot-before-hidden-again\r\n', + cols: 100, + rows: 30, + seq: hidden.length + visibleLive.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenCalledWith( + 'snapshot-before-hidden-again\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenAgain, expect.any(Function)) + expect(transport.resize).not.toHaveBeenCalled() + expect(signalPty).not.toHaveBeenCalledWith('pty-id', 'SIGWINCH') + ;(deps.isVisibleRef as { current: boolean }).current = true + visibilityState = 'visible' + visibilityChangeHandler.current?.() + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(pane.terminal.write).toHaveBeenCalledWith( + 'snapshot-after-hidden-again\r\n', + expect.any(Function) + ) + disposable.dispose() + }) + + it('does not signal SIGWINCH after hidden-backlog snapshot replay when dimensions are unchanged', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const signalPty = window.api.pty.signal as unknown as ReturnType + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 120, + rows: 40, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + transport.resize.mockClear() + signalPty.mockClear() + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function)) + expect(transport.resize).not.toHaveBeenCalledWith(120, 40) + expect(signalPty).not.toHaveBeenCalledWith('pty-id', 'SIGWINCH') + disposable.dispose() + }) + + it('writes a background-origin alternate-screen repaint that arrives after the reveal restore', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { + seq?: number + rawLength?: number + background?: boolean + droppedOutput?: boolean + } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const signalPty = window.api.pty.signal as unknown as ReturnType + getMainBufferSnapshot.mockResolvedValue({ + data: 'restored-frame', + cols: 133, + rows: 40, + seq: 100, + alternateScreen: true + }) + // OpenCode's full repaint after reveal; a differential renderer never repaints these cells again. + const fullRepaint = '\x1b[H\x1b[2Kfull opencode repaint' + + const pane = createPane(1) + pane.terminal.cols = 133 + pane.terminal.rows = 40 + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: true } + }) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const settleWrites = async (): Promise => { + for (let round = 0; round < 8; round += 1) { + await flushAsyncTicks(2) + await new Promise((resolve) => setTimeout(resolve, 60)) + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + } + } + } + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('', { seq: 100, rawLength: 0, droppedOutput: true }) + await settleWrites() + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(writes).toContain('restored-frame') + transport.resize.mockClear() + signalPty.mockClear() + + capturedDataCallback.current?.(fullRepaint, { + seq: 100 + fullRepaint.length, + rawLength: fullRepaint.length, + background: true + }) + await settleWrites() + + expect(writes.join('')).toContain(fullRepaint) + expect(transport.resize).not.toHaveBeenCalled() + expect(signalPty).not.toHaveBeenCalled() + disposable.dispose() + }) + + it('re-restores a skipped alt frame from the model when the fit lands back on the capture grid', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; droppedOutput?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + // The first replay measures a transient narrower grid; the fit then lands back on the capture grid. + let narrowUntilWriteCount: number | null = null + getMainBufferSnapshot.mockImplementation(async () => { + narrowUntilWriteCount = getMainBufferSnapshot.mock.calls.length === 1 ? writes.length : null + return { + data: '\x1b[?1049hcomposed-frame', + scrollbackAnsi: '', + frameRestoreAnsi: 'model-frame', + cols: 133, + rows: 40, + seq: 100, + alternateScreen: true + } + }) + + const pane = createPane(1) + pane.terminal.cols = 133 + pane.terminal.rows = 40 + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const manager = createManager(1) + const deps = createDeps({ isVisibleRef: { current: true } }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + transport.resize.mockClear() + pane.fitAddon.proposeDimensions.mockImplementation(() => + narrowUntilWriteCount === writes.length + ? { cols: 120, rows: 40 } + : { cols: pane.terminal.cols, rows: pane.terminal.rows } + ) + + capturedDataCallback.current?.('', { seq: 100, droppedOutput: true }) + for (let round = 0; round < 8; round += 1) { + await flushAsyncTicks(2) + await new Promise((resolve) => setTimeout(resolve, 60)) + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + } + } + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writes.join('')).toContain('model-frame') + expect(transport.resize).not.toHaveBeenCalledWith(132, 40) + disposable.dispose() + }) + + it('restores a dropped daemon alt frame from the model when the fit lands back on the capture grid', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + getMainBufferSnapshot.mockResolvedValue({ + data: '\x1b[?1049hMODEL-FRAME', + scrollbackAnsi: '', + frameRestoreAnsi: 'MODEL-RESTORE', + cols: 200, + rows: 50, + seq: 10, + alternateScreen: true + }) + + const pane = createPane(1) + pane.terminal.cols = 200 + pane.terminal.rows = 50 + const writtenText = (): string => pane.terminal.write.mock.calls.map((c) => c[0]).join('') + // The replay measures a transient narrower grid; the fit then lands back on the capture grid. + pane.fitAddon.proposeDimensions = vi.fn(() => + writtenText().includes('RESTORE-LIVE-STATE') + ? { cols: 200, rows: 50 } + : { cols: 120, rows: 40 } + ) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(40) + + expect(writtenText()).not.toContain('ALT-FRAME-BODY') + expect(getMainBufferSnapshot).toHaveBeenCalledWith('tab-pty', expect.anything()) + await vi.waitFor(() => expect(writtenText()).toContain('MODEL-FRAME')) + disposable.dispose() + }) + + it('does not forward terminal resizes while the pane is hidden', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const isVisibleRef = { current: false } + const deps = createDeps({ isVisibleRef }) + + const disposable = connectPanePty(pane as never, manager as never, deps as never) + const onResizeMock = pane.terminal.onResize as unknown as { + mock: { calls: [[(event: { cols: number; rows: number }) => void] | []] } + } + const resizeHandler = onResizeMock.mock.calls[0]?.[0] + if (!resizeHandler) { + throw new Error('Expected terminal resize handler to be registered') + } + + transport.resize.mockClear() + resizeHandler({ cols: 121, rows: 41 }) + + expect(transport.resize).not.toHaveBeenCalled() + + isVisibleRef.current = true + resizeHandler({ cols: 122, rows: 42 }) + + expect(transport.resize).toHaveBeenCalledWith(122, 42, { claim: true }) + disposable.dispose() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts deleted file mode 100644 index 26391326702..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts +++ /dev/null @@ -1,527 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' -import { - LEAF_1, - captureCallbackTerminalWrites, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('keeps recovery pending when hidden output arrives during an in-flight snapshot', async () => { - let visibilityState: DocumentVisibilityState = 'visible' - // Fire-all like a real event target: both the pane resync and stale-visibility trust handlers listen for visibilitychange. - const visibilityChangeListeners: (() => void)[] = [] - const visibilityChangeHandler = { - current: (): void => { - for (const listener of visibilityChangeListeners) { - listener() - } - } - } - ;(globalThis as { document?: Document }).document = { - get visibilityState() { - return visibilityState - }, - addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { - if (type === 'visibilitychange') { - visibilityChangeListeners.push(listener as () => void) - } - }), - removeEventListener: vi.fn() - } as unknown as Document - - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const signalPty = window.api.pty.signal as unknown as ReturnType - const firstSnapshot = createDeferred<{ - data: string - cols: number - rows: number - seq: number - }>() - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const visibleLive = 'visible-before-hide\r\n' - const hiddenAgain = 'hidden-during-restore\r\n' - getMainBufferSnapshot.mockReturnValueOnce(firstSnapshot.promise).mockResolvedValueOnce({ - data: 'snapshot-after-hidden-again\r\n', - cols: 120, - rows: 40, - seq: hidden.length + visibleLive.length + hiddenAgain.length - }) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - visibilityState = 'visible' - capturedDataCallback.current?.(visibleLive, { - seq: hidden.length + visibleLive.length, - rawLength: visibleLive.length - }) - await flushAsyncTicks(2) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - - ;(deps.isVisibleRef as { current: boolean }).current = false - visibilityState = 'hidden' - capturedDataCallback.current?.(hiddenAgain, { - seq: hidden.length + visibleLive.length + hiddenAgain.length, - rawLength: hiddenAgain.length - }) - transport.resize.mockClear() - signalPty.mockClear() - firstSnapshot.resolve({ - data: 'snapshot-before-hidden-again\r\n', - cols: 100, - rows: 30, - seq: hidden.length + visibleLive.length - }) - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - expect(pane.terminal.write).toHaveBeenCalledWith( - 'snapshot-before-hidden-again\r\n', - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenAgain, expect.any(Function)) - expect(transport.resize).not.toHaveBeenCalled() - expect(signalPty).not.toHaveBeenCalledWith('pty-id', 'SIGWINCH') - - ;(deps.isVisibleRef as { current: boolean }).current = true - visibilityState = 'visible' - visibilityChangeHandler.current?.() - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) - expect(pane.terminal.write).toHaveBeenCalledWith( - 'snapshot-after-hidden-again\r\n', - expect.any(Function) - ) - disposable.dispose() - }) - - it('does not signal SIGWINCH after hidden-backlog snapshot replay when dimensions are unchanged', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const signalPty = window.api.pty.signal as unknown as ReturnType - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const live = 'visible-after\r\n' - getMainBufferSnapshot.mockResolvedValue({ - data: 'snapshot-state\r\n', - cols: 120, - rows: 40, - seq: hidden.length + live.length - }) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - transport.resize.mockClear() - signalPty.mockClear() - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function)) - expect(transport.resize).not.toHaveBeenCalledWith(120, 40) - expect(signalPty).not.toHaveBeenCalledWith('pty-id', 'SIGWINCH') - disposable.dispose() - }) - - it('writes a background-origin alternate-screen repaint that arrives after the reveal restore', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: - | (( - data: string, - meta?: { - seq?: number - rawLength?: number - background?: boolean - droppedOutput?: boolean - } - ) => void) - | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const signalPty = window.api.pty.signal as unknown as ReturnType - getMainBufferSnapshot.mockResolvedValue({ - data: 'restored-frame', - cols: 133, - rows: 40, - seq: 100, - alternateScreen: true - }) - // OpenCode's full repaint after reveal; a differential renderer never repaints these cells again. - const fullRepaint = '\x1b[H\x1b[2Kfull opencode repaint' - - const pane = createPane(1) - pane.terminal.cols = 133 - pane.terminal.rows = 40 - ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: true } - }) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const settleWrites = async (): Promise => { - for (let round = 0; round < 8; round += 1) { - await flushAsyncTicks(2) - await new Promise((resolve) => setTimeout(resolve, 60)) - while (parseCallbacks.length > 0) { - parseCallbacks.shift()?.() - } - } - } - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('', { seq: 100, rawLength: 0, droppedOutput: true }) - await settleWrites() - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - expect(writes).toContain('restored-frame') - transport.resize.mockClear() - signalPty.mockClear() - - capturedDataCallback.current?.(fullRepaint, { - seq: 100 + fullRepaint.length, - rawLength: fullRepaint.length, - background: true - }) - await settleWrites() - - expect(writes.join('')).toContain(fullRepaint) - expect(transport.resize).not.toHaveBeenCalled() - expect(signalPty).not.toHaveBeenCalled() - disposable.dispose() - }) - - it('re-restores a skipped alt frame from the model when the fit lands back on the capture grid', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; droppedOutput?: boolean }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - // The first replay measures a transient narrower grid; the fit then lands back on the capture grid. - let narrowUntilWriteCount: number | null = null - getMainBufferSnapshot.mockImplementation(async () => { - narrowUntilWriteCount = getMainBufferSnapshot.mock.calls.length === 1 ? writes.length : null - return { - data: '\x1b[?1049hcomposed-frame', - scrollbackAnsi: '', - frameRestoreAnsi: 'model-frame', - cols: 133, - rows: 40, - seq: 100, - alternateScreen: true - } - }) - - const pane = createPane(1) - pane.terminal.cols = 133 - pane.terminal.rows = 40 - ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const manager = createManager(1) - const deps = createDeps({ isVisibleRef: { current: true } }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - transport.resize.mockClear() - pane.fitAddon.proposeDimensions.mockImplementation(() => - narrowUntilWriteCount === writes.length - ? { cols: 120, rows: 40 } - : { cols: pane.terminal.cols, rows: pane.terminal.rows } - ) - - capturedDataCallback.current?.('', { seq: 100, droppedOutput: true }) - for (let round = 0; round < 8; round += 1) { - await flushAsyncTicks(2) - await new Promise((resolve) => setTimeout(resolve, 60)) - while (parseCallbacks.length > 0) { - parseCallbacks.shift()?.() - } - } - - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) - expect(writes.join('')).toContain('model-frame') - expect(transport.resize).not.toHaveBeenCalledWith(132, 40) - disposable.dispose() - }) - - it('restores a dropped daemon alt frame from the model when the fit lands back on the capture grid', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => - sessionId - ? { - id: sessionId, - snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', - snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', - snapshotFrameAnsi: 'ALT-FRAME-BODY', - snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', - snapshotCols: 200, - snapshotRows: 50, - isAlternateScreen: true - } - : null - ) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } - } - const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) - getMainBufferSnapshot.mockResolvedValue({ - data: '\x1b[?1049hMODEL-FRAME', - scrollbackAnsi: '', - frameRestoreAnsi: 'MODEL-RESTORE', - cols: 200, - rows: 50, - seq: 10, - alternateScreen: true - }) - - const pane = createPane(1) - pane.terminal.cols = 200 - pane.terminal.rows = 50 - const writtenText = (): string => pane.terminal.write.mock.calls.map((c) => c[0]).join('') - // The replay measures a transient narrower grid; the fit then lands back on the capture grid. - pane.fitAddon.proposeDimensions = vi.fn(() => - writtenText().includes('RESTORE-LIVE-STATE') - ? { cols: 200, rows: 50 } - : { cols: 120, rows: 40 } - ) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(40) - - expect(writtenText()).not.toContain('ALT-FRAME-BODY') - expect(getMainBufferSnapshot).toHaveBeenCalledWith('tab-pty', expect.anything()) - await vi.waitFor(() => expect(writtenText()).toContain('MODEL-FRAME')) - disposable.dispose() - }) - - it('does not forward terminal resizes while the pane is hidden', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - transportFactoryQueue.push(transport) - const pane = createPane(1) - const manager = createManager(1) - const isVisibleRef = { current: false } - const deps = createDeps({ isVisibleRef }) - - const disposable = connectPanePty(pane as never, manager as never, deps as never) - const onResizeMock = pane.terminal.onResize as unknown as { - mock: { calls: [[(event: { cols: number; rows: number }) => void] | []] } - } - const resizeHandler = onResizeMock.mock.calls[0]?.[0] - if (!resizeHandler) { - throw new Error('Expected terminal resize handler to be registered') - } - - transport.resize.mockClear() - resizeHandler({ cols: 121, rows: 41 }) - - expect(transport.resize).not.toHaveBeenCalled() - - isVisibleRef.current = true - resizeHandler({ cols: 122, rows: 42 }) - - expect(transport.resize).toHaveBeenCalledWith(122, 42, { claim: true }) - disposable.dispose() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.spec.ts new file mode 100644 index 00000000000..90143df5d0d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.spec.ts @@ -0,0 +1,177 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionHiddenTuiSnapshotReplayTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('replays rich headless snapshots as the future hidden TUI view source', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const richSnapshot = [ + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + '\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n', + '\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n', + '\x1b[2;36m│ status streaming │\x1b[0m\r\n', + '\x1b[2;36m╰────────────────────────────╯\x1b[0m', + '\x1b[6;4H\x1b[?25h' + ].join('') + const visibleTrigger = 'visible-trigger\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: richSnapshot, + cols: 96, + rows: 18, + seq: hidden.length + visibleTrigger.length, + source: 'headless' + }) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleTrigger, { + seq: hidden.length + visibleTrigger.length, + rawLength: visibleTrigger.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.resize).toHaveBeenCalledWith(96, 18) + expect(pane.terminal.write).toHaveBeenCalledWith(richSnapshot, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(visibleTrigger, expect.any(Function)) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(deps.replayingPanesRef.current.size).toBe(0) + disposable.dispose() + }) + + it('refreshes visible rows after replaying a hidden TUI snapshot', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = '\x1b[?2026h\x1b[2J\x1b[H╭────╮\r\n│ ok │\r\n╰────╯\x1b[?2026l' + getMainBufferSnapshot.mockResolvedValue({ + data: live, + cols: 120, + rows: 40, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + // Why: hidden restore replays bypass live foreground output; force a paint after parse so stale WebGL cells can't survive. + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(deps.replayingPanesRef.current.size).toBe(0) + disposable.dispose() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.test.ts deleted file mode 100644 index eebe3ffbf42..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('replays rich headless snapshots as the future hidden TUI view source', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const richSnapshot = [ - '\x1b[?1049h', - '\x1b[2J\x1b[H', - '\x1b[?25l', - '\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n', - '\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n', - '\x1b[2;36m│ status streaming │\x1b[0m\r\n', - '\x1b[2;36m╰────────────────────────────╯\x1b[0m', - '\x1b[6;4H\x1b[?25h' - ].join('') - const visibleTrigger = 'visible-trigger\r\n' - getMainBufferSnapshot.mockResolvedValue({ - data: richSnapshot, - cols: 96, - rows: 18, - seq: hidden.length + visibleTrigger.length, - source: 'headless' - }) - - const pane = createPane(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(visibleTrigger, { - seq: hidden.length + visibleTrigger.length, - rawLength: visibleTrigger.length - }) - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) - expect(pane.terminal.resize).toHaveBeenCalledWith(96, 18) - expect(pane.terminal.write).toHaveBeenCalledWith(richSnapshot, expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith(visibleTrigger, expect.any(Function)) - expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(deps.replayingPanesRef.current.size).toBe(0) - disposable.dispose() - }) - - it('refreshes visible rows after replaying a hidden TUI snapshot', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const live = '\x1b[?2026h\x1b[2J\x1b[H╭────╮\r\n│ ok │\r\n╰────╯\x1b[?2026l' - getMainBufferSnapshot.mockResolvedValue({ - data: live, - cols: 120, - rows: 40, - seq: hidden.length + live.length - }) - - const pane = createPane(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - - // Why: hidden restore replays bypass live foreground output; force a paint after parse so stale WebGL cells can't survive. - expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(deps.replayingPanesRef.current.size).toBe(0) - disposable.dispose() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.spec.ts new file mode 100644 index 00000000000..39cf0380570 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.spec.ts @@ -0,0 +1,430 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { + AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, + AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS +} from './pty-connection-test-constants' +import { + LEAF_1, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { + createInitialStoreState, + buildActiveRuntimeEnvironmentState +} from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionHookCompletionBellArbitrationTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + // Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. + function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { + mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('leaves SSH IPC OSC 9999 status ownership in the main runtime', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-ssh') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([['conn-1', { status: 'connected' }]]) + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(createdTransportOptions[0]?.onAgentStatus).toBeUndefined() + }) + + it('lets delayed hook completion notifications win over concurrent terminal bells', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + mockStoreState.ptyIdsByTabId = { 'tab-1': ['pty-hook'] } + mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { [LEAF_1]: 'pty-hook' } + + vi.useFakeTimers() + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { + state: 'working' | 'done' + prompt: string + agentType: 'codex' + lastAssistantMessage?: string + }) => void) + | undefined + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + if (!statusHandler || !bellHandler) { + throw new Error('Expected hook status and bell handlers to be registered') + } + + statusHandler({ + state: 'working', + prompt: 'finish the implementation', + agentType: 'codex' + }) + statusHandler({ + state: 'done', + prompt: 'finish the implementation', + agentType: 'codex', + lastAssistantMessage: 'Done.' + }) + bellHandler() + + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + + vi.advanceTimersByTime( + AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS - AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS + ) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey: makePaneKey('tab-1', LEAF_1), + agentStatusSnapshot: expect.objectContaining({ + state: 'done', + prompt: 'finish the implementation', + agentType: 'codex', + lastAssistantMessage: 'Done.' + }) + }) + ) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + }) + + it('restores a suppressed terminal bell when a delayed hook completion resumes work', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + mockStoreState.ptyIdsByTabId = { 'tab-1': ['tab-pty'] } + mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { [LEAF_1]: 'tab-pty' } + + vi.useFakeTimers() + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { + state: 'working' | 'done' + prompt: string + agentType: 'codex' + lastAssistantMessage?: string + }) => void) + | undefined + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + if (!statusHandler || !bellHandler) { + throw new Error('Expected hook status and bell handlers to be registered') + } + + statusHandler({ + state: 'working', + prompt: 'finish the implementation', + agentType: 'codex' + }) + statusHandler({ + state: 'done', + prompt: 'finish the implementation', + agentType: 'codex', + lastAssistantMessage: 'Done.' + }) + bellHandler() + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + + statusHandler({ + state: 'working', + prompt: 'finish the implementation', + agentType: 'codex' + }) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('restores a suppressed terminal bell when a delayed hook completion resumes via title tracking', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + mockStoreState.ptyIdsByTabId = { 'tab-1': ['pty-hook'] } + mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { [LEAF_1]: 'pty-hook' } + + vi.useFakeTimers() + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { + state: 'working' | 'done' + prompt: string + agentType: 'codex' + lastAssistantMessage?: string + }) => void) + | undefined + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as + | (() => void) + | undefined + if (!statusHandler || !bellHandler || !workingHandler) { + throw new Error('Expected hook status, bell, and working handlers to be registered') + } + + statusHandler({ + state: 'working', + prompt: 'finish the implementation', + agentType: 'codex' + }) + statusHandler({ + state: 'done', + prompt: 'finish the implementation', + agentType: 'codex', + lastAssistantMessage: 'Milestone complete.' + }) + bellHandler() + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + + workingHandler() + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('restores a suppressed terminal bell when the pending agent completion is canceled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as + | (() => void) + | undefined + if (!bellHandler || !idleHandler || !workingHandler) { + throw new Error('Expected bell, idle, and working handlers to be registered') + } + + bellHandler() + idleHandler('* Codex done') + workingHandler() + vi.advanceTimersByTime(250) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('cancels a title task-complete notification when the agent resumes before quiet', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as + | (() => void) + | undefined + if (!idleHandler || !workingHandler) { + throw new Error('Expected idle and working handlers to be registered') + } + + idleHandler('* Codex done') + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS - 1) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + + workingHandler() + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + + idleHandler('* Codex done') + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith({ + source: 'agent-task-complete', + terminalTitle: '* Codex done', + paneKey: makePaneKey('tab-1', LEAF_1), + ptyId: 'tab-pty' + }) + }) + + it('forwards raw onData after a bell as a query reply', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + if (!bellHandler || !onDataHandler) { + throw new Error('expected bell and onData handlers to be registered') + } + + bellHandler() + ;(onDataHandler as (data: string) => void)('a') + + expect(deps.markTerminalBell).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + + tabId: 'tab-1', + + paneKey: makePaneKey('tab-1', LEAF_1) + }) + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') + }) + + // Why: on a stale-codex pane (pending account-switch restart), production blocks sendInput. + it('blocks onData on a stale codex pane', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex-stale') + transportFactoryQueue.push(transport) + // isCodexPaneStale reads codexRestartNoticeByPtyId; seed a restart notice to trigger the stale branch. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-codex-stale' }] + }, + ptyIdsByTabId: { + 'tab-1': ['pty-codex-stale'] + }, + codexRestartNoticeByPtyId: { + 'pty-codex-stale': { previousAccountLabel: 'A', nextAccountLabel: 'B' } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + // Stale-codex input is also blocked from reaching the transport. + expect(transport.sendInput).not.toHaveBeenCalled() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts deleted file mode 100644 index 16caa88274c..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { - AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, - AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS -} from './pty-connection-test-constants' -import { - LEAF_1, - createMockTransport, - createPane, - createManager -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { - createInitialStoreState, - buildActiveRuntimeEnvironmentState -} from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import type { MockTransport } from './pty-connection-test-pane-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. -function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { - mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('leaves SSH IPC OSC 9999 status ownership in the main runtime', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-ssh') - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - repos: [{ id: 'repo1', connectionId: 'conn-1' }], - sshConnectionStates: new Map([['conn-1', { status: 'connected' }]]) - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(createdTransportOptions[0]?.onAgentStatus).toBeUndefined() - }) - - it('lets delayed hook completion notifications win over concurrent terminal bells', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-hook') - transportFactoryQueue.push(transport) - enableActiveRuntimeEnvironment() - mockStoreState.ptyIdsByTabId = { 'tab-1': ['pty-hook'] } - mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { [LEAF_1]: 'pty-hook' } - - vi.useFakeTimers() - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const statusHandler = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { - state: 'working' | 'done' - prompt: string - agentType: 'codex' - lastAssistantMessage?: string - }) => void) - | undefined - const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined - if (!statusHandler || !bellHandler) { - throw new Error('Expected hook status and bell handlers to be registered') - } - - statusHandler({ - state: 'working', - prompt: 'finish the implementation', - agentType: 'codex' - }) - statusHandler({ - state: 'done', - prompt: 'finish the implementation', - agentType: 'codex', - lastAssistantMessage: 'Done.' - }) - bellHandler() - - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'terminal-bell' }) - ) - - vi.advanceTimersByTime( - AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS - AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS - ) - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'terminal-bell' }) - ) - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'agent-task-complete', - terminalTitle: 'codex', - paneKey: makePaneKey('tab-1', LEAF_1), - agentStatusSnapshot: expect.objectContaining({ - state: 'done', - prompt: 'finish the implementation', - agentType: 'codex', - lastAssistantMessage: 'Done.' - }) - }) - ) - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'terminal-bell' }) - ) - }) - - it('restores a suppressed terminal bell when a delayed hook completion resumes work', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transportFactoryQueue.push(transport) - enableActiveRuntimeEnvironment() - mockStoreState.ptyIdsByTabId = { 'tab-1': ['tab-pty'] } - mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { [LEAF_1]: 'tab-pty' } - - vi.useFakeTimers() - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const statusHandler = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { - state: 'working' | 'done' - prompt: string - agentType: 'codex' - lastAssistantMessage?: string - }) => void) - | undefined - const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined - if (!statusHandler || !bellHandler) { - throw new Error('Expected hook status and bell handlers to be registered') - } - - statusHandler({ - state: 'working', - prompt: 'finish the implementation', - agentType: 'codex' - }) - statusHandler({ - state: 'done', - prompt: 'finish the implementation', - agentType: 'codex', - lastAssistantMessage: 'Done.' - }) - bellHandler() - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'terminal-bell' }) - ) - - statusHandler({ - state: 'working', - prompt: 'finish the implementation', - agentType: 'codex' - }) - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) - - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ source: 'terminal-bell' }) - ) - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - }) - - it('restores a suppressed terminal bell when a delayed hook completion resumes via title tracking', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-hook') - transportFactoryQueue.push(transport) - enableActiveRuntimeEnvironment() - mockStoreState.ptyIdsByTabId = { 'tab-1': ['pty-hook'] } - mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { [LEAF_1]: 'pty-hook' } - - vi.useFakeTimers() - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const statusHandler = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { - state: 'working' | 'done' - prompt: string - agentType: 'codex' - lastAssistantMessage?: string - }) => void) - | undefined - const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined - const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as - | (() => void) - | undefined - if (!statusHandler || !bellHandler || !workingHandler) { - throw new Error('Expected hook status, bell, and working handlers to be registered') - } - - statusHandler({ - state: 'working', - prompt: 'finish the implementation', - agentType: 'codex' - }) - statusHandler({ - state: 'done', - prompt: 'finish the implementation', - agentType: 'codex', - lastAssistantMessage: 'Milestone complete.' - }) - bellHandler() - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'terminal-bell' }) - ) - - workingHandler() - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) - - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ source: 'terminal-bell' }) - ) - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - }) - - it('restores a suppressed terminal bell when the pending agent completion is canceled', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as - | (() => void) - | undefined - if (!bellHandler || !idleHandler || !workingHandler) { - throw new Error('Expected bell, idle, and working handlers to be registered') - } - - bellHandler() - idleHandler('* Codex done') - workingHandler() - vi.advanceTimersByTime(250) - - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ source: 'terminal-bell' }) - ) - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - }) - - it('cancels a title task-complete notification when the agent resumes before quiet', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as - | (() => void) - | undefined - if (!idleHandler || !workingHandler) { - throw new Error('Expected idle and working handlers to be registered') - } - - idleHandler('* Codex done') - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS - 1) - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - - workingHandler() - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - - idleHandler('* Codex done') - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - - expect(deps.dispatchNotification).toHaveBeenCalledWith({ - source: 'agent-task-complete', - terminalTitle: '* Codex done', - paneKey: makePaneKey('tab-1', LEAF_1), - ptyId: 'tab-pty' - }) - }) - - it('forwards raw onData after a bell as a query reply', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined - if (!bellHandler || !onDataHandler) { - throw new Error('expected bell and onData handlers to be registered') - } - - bellHandler() - ;(onDataHandler as (data: string) => void)('a') - - expect(deps.markTerminalBell).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - - tabId: 'tab-1', - - paneKey: makePaneKey('tab-1', LEAF_1) - }) - expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') - }) - - // Why: on a stale-codex pane (pending account-switch restart), production blocks sendInput. - it('blocks onData on a stale codex pane', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-codex-stale') - transportFactoryQueue.push(transport) - // isCodexPaneStale reads codexRestartNoticeByPtyId; seed a restart notice to trigger the stale branch. - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'pty-codex-stale' }] - }, - ptyIdsByTabId: { - 'tab-1': ['pty-codex-stale'] - }, - codexRestartNoticeByPtyId: { - 'pty-codex-stale': { previousAccountLabel: 'A', nextAccountLabel: 'B' } - } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as (data: string) => void)('a') - - // Stale-codex input is also blocked from reaching the transport. - expect(transport.sendInput).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.spec.ts new file mode 100644 index 00000000000..0f8d3945de7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.spec.ts @@ -0,0 +1,585 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './pty-connection-test-constants' +import { + temporarilySetNavigatorUserAgent, + sendTerminalInputThroughPane +} from './pty-connection-test-dom' +import { + LEAF_1, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { + createInitialStoreState, + buildActiveRuntimeEnvironmentState +} from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionHookCompletionSideEffectsTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + // Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. + function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { + mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) + } + + function notifyStoreSubscribers(): void { + for (const listener of storeSubscribers.slice()) { + listener(mockStoreState) + } + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('preserves permission-title cursor and cache side effects through authoritative hook done', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'waiting', + prompt: 'approve the tool call', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude', + paneKey, + stateHistory: [] + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { + state: 'done' + prompt: string + agentType: 'claude' + lastAssistantMessage: string + }) => void) + | undefined + if (!idleHandler || !statusHandler) { + throw new Error('Expected idle and hook status handlers to be registered') + } + + idleHandler('Claude Code permission') + + expect(deps.dispatchNotification).not.toHaveBeenCalled() + expect(deps.setCacheTimerStartedAt).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + + statusHandler({ + state: 'done', + prompt: 'approve the tool call', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + + expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, expect.any(Number)) + }) + + it('preserves a genuine hook completion after suppressing an earlier idle title', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'still working', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude', + paneKey, + stateHistory: [] + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { + state: 'done' + prompt: string + agentType: 'claude' + lastAssistantMessage: string + }) => void) + | undefined + if (!titleHandler || !idleHandler || !statusHandler) { + throw new Error('Expected title, idle, and hook status handlers to be registered') + } + + titleHandler('Claude working', 'Claude working') + titleHandler('Claude done', 'Claude done') + idleHandler('Claude done') + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).not.toHaveBeenCalled() + + statusHandler({ + state: 'done', + prompt: 'finish the implementation', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + notifyStoreSubscribers() + expect(deps.setCacheTimerStartedAt).not.toHaveBeenCalledWith(paneKey, expect.any(Number)) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS * 2) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent-task-complete', + paneKey, + agentStatusSnapshot: expect.objectContaining({ + state: 'done', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + }) + ) + expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, expect.any(Number)) + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + expect(storeSubscribers).toHaveLength(1) + }) + + it('applies accepted hook side effects when every completion alert consumer is disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + mockStoreState.ptyIdsByTabId = { 'tab-1': ['pty-hook'] } + mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { [LEAF_1]: 'pty-hook' } + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: false, + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { + state: 'working' | 'done' + prompt: string + agentType: 'claude' + lastAssistantMessage?: string + }) => void) + | undefined + if (!idleHandler || !statusHandler) { + throw new Error('Expected idle and hook status handlers to be registered') + } + + statusHandler({ + state: 'working', + prompt: 'finish the implementation', + agentType: 'claude' + }) + idleHandler('Claude done') + + expect(deps.setCacheTimerStartedAt).not.toHaveBeenCalledWith(paneKey, expect.any(Number)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + + statusHandler({ + state: 'done', + prompt: 'finish the implementation', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).not.toHaveBeenCalled() + expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, expect.any(Number)) + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + }) + + it.each([ + { + name: 'delivers confirmed process exit despite the same stale working hook row', + hookUpdateBeforeDispatch: 'none' + }, + { + name: 'delivers confirmed process exit after a same-turn working hook refresh', + hookUpdateBeforeDispatch: 'same-turn' + }, + { + name: 'delivers confirmed process exit after same-turn hook identity becomes known', + hookUpdateBeforeDispatch: 'same-turn-known-agent' + }, + { + name: 'cancels confirmed process exit delivery when a newer working hook row arrives', + hookUpdateBeforeDispatch: 'new-turn' + } + ] as const)('$name', async ({ hookUpdateBeforeDispatch }) => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-crashed-codex') + transportFactoryQueue.push(transport) + vi.useFakeTimers() + // Pin process cadence so confirmed exit precedes the hook refresh. + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5) + + try { + const paneKey = makePaneKey('tab-1', LEAF_1) + const crashedTurnStartedAt = Date.now() + const initialAgentType = + hookUpdateBeforeDispatch === 'same-turn-known-agent' ? 'unknown' : 'codex' + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'crash before done hook', + updatedAt: crashedTurnStartedAt, + stateStartedAt: crashedTurnStartedAt, + agentType: initialAgentType, + paneKey, + stateHistory: [] + } + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('Codex working', 'Codex working') + await vi.advanceTimersByTimeAsync(2_500) + getForegroundProcess.mockResolvedValue(null) + await vi.advanceTimersByTimeAsync(3_000) + if (hookUpdateBeforeDispatch !== 'none') { + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: + hookUpdateBeforeDispatch === 'new-turn' + ? 'new turn after the prior process exited' + : 'same turn hook detail refresh', + updatedAt: Date.now(), + stateStartedAt: + hookUpdateBeforeDispatch === 'new-turn' ? Date.now() : crashedTurnStartedAt, + agentType: 'codex', + paneKey, + stateHistory: [] + } + notifyStoreSubscribers() + } + await vi.advanceTimersByTimeAsync(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + const expectedNotification = { + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey, + ptyId: 'tab-pty', + agentCompletionSource: 'process-exit' + } + if (hookUpdateBeforeDispatch === 'new-turn') { + expect(deps.dispatchNotification).not.toHaveBeenCalledWith(expectedNotification) + } else { + expect(deps.dispatchNotification).toHaveBeenCalledWith(expectedNotification) + } + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + transport.sendInput.mockClear() + sendTerminalInputThroughPane(pane, '\x1b[I') + sendTerminalInputThroughPane(pane, '\x7f') + expect(transport.sendInput).toHaveBeenCalledTimes(2) + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[I', 'query-reply') + expect(transport.sendInput).toHaveBeenLastCalledWith('\x7f', 'query-reply') + } finally { + randomSpy.mockRestore() + restoreUserAgent() + } + }) + + it('drops an exited agent completion when a replacement agent hook row is active', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-replaced-codex') + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('Codex working', 'Codex working') + await vi.advanceTimersByTimeAsync(2_500) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'replacement agent turn', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude', + paneKey, + stateHistory: [] + } + getForegroundProcess.mockResolvedValue('claude') + await vi.advanceTimersByTimeAsync(1_000) + await vi.advanceTimersByTimeAsync(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'replacement agent turn', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude', + paneKey, + stateHistory: [] + } + titleHandler('Claude done', 'Claude done') + await vi.advanceTimersByTimeAsync(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent-task-complete', + terminalTitle: 'Claude done', + paneKey + }) + ) + }) + + it('drops confirmed idle exit when a different hook owner appears between null samples', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-replaced-codex') + transportFactoryQueue.push(transport) + vi.useFakeTimers() + // Why: pin poll jitter so follow-up null samples cannot overtake replacement ownership. + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5) + try { + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + // Why: one timer advance can start multiple reads; gate follow-up null samples until the replacement hook owner is installed. + let idleMode = false + let nullSamplesStarted = 0 + let releaseConfirmingNullSample: (() => void) | undefined + const confirmingNullSampleGate = new Promise((resolve) => { + releaseConfirmingNullSample = resolve + }) + getForegroundProcess.mockImplementation(async () => { + if (!idleMode) { + return 'codex' + } + nullSamplesStarted += 1 + if (nullSamplesStarted >= 2) { + await confirmingNullSampleGate + } + return null + }) + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!titleHandler || !idleHandler) { + throw new Error('Expected title and idle handlers to be registered') + } + + titleHandler('Codex working', 'Codex working') + await vi.advanceTimersByTimeAsync(2_500) + idleMode = true + for (let attempts = 0; nullSamplesStarted < 1; attempts += 1) { + if (attempts >= 10) { + throw new Error('Expected the first idle process inspection') + } + await vi.advanceTimersToNextTimerAsync() + } + // Why: let the first null sample record a pending process exit before the replacement owner is installed. + await flushAsyncTicks() + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'replacement agent turn', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude', + paneKey, + stateHistory: [] + } + releaseConfirmingNullSample?.() + // Ignore cursor resets from setup so this assertion only covers the replacement idle event. + pane.terminal.write.mockClear() + idleHandler('Claude done') + await vi.advanceTimersByTimeAsync(800) + await vi.advanceTimersByTimeAsync(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + } finally { + // Why: an assertion failure above must not leave Math.random pinned for later tests. + randomSpy.mockRestore() + } + }) + + it('preserves replacement-agent title side effects through the process replacement veto', async () => { + const { dispatchAgentHookTerminalLifecycle } = await import('./agent-hook-terminal-lifecycle') + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-replaced-codex') + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!titleHandler || !idleHandler) { + throw new Error('Expected title and idle handlers to be registered') + } + + titleHandler('Codex working', 'Codex working') + await vi.advanceTimersByTimeAsync(2_500) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'replacement agent turn', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude', + paneKey, + stateHistory: [] + } + idleHandler('Claude done') + getForegroundProcess.mockResolvedValue('claude') + await vi.advanceTimersByTimeAsync(1_000) + + dispatchAgentHookTerminalLifecycle(paneKey, { + state: 'done', + prompt: 'replacement agent turn', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + + expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, expect.any(Number)) + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts deleted file mode 100644 index 4bd6747c02a..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts +++ /dev/null @@ -1,676 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './pty-connection-test-constants' -import { - temporarilySetNavigatorUserAgent, - sendTerminalInputThroughPane -} from './pty-connection-test-dom' -import { - LEAF_1, - createMockTransport, - createPane, - createManager -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { - createInitialStoreState, - buildActiveRuntimeEnvironmentState -} from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import type { MockTransport } from './pty-connection-test-pane-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. -function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { - mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) -} - -function notifyStoreSubscribers(): void { - for (const listener of storeSubscribers.slice()) { - listener(mockStoreState) - } -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('preserves permission-title cursor and cache side effects through authoritative hook done', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-hook') - transportFactoryQueue.push(transport) - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'waiting', - prompt: 'approve the tool call', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'claude', - paneKey, - stateHistory: [] - } - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - const statusHandler = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { - state: 'done' - prompt: string - agentType: 'claude' - lastAssistantMessage: string - }) => void) - | undefined - if (!idleHandler || !statusHandler) { - throw new Error('Expected idle and hook status handlers to be registered') - } - - idleHandler('Claude Code permission') - - expect(deps.dispatchNotification).not.toHaveBeenCalled() - expect(deps.setCacheTimerStartedAt).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - - statusHandler({ - state: 'done', - prompt: 'approve the tool call', - agentType: 'claude', - lastAssistantMessage: 'Done.' - }) - - expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, expect.any(Number)) - }) - - it('preserves a genuine hook completion after suppressing an earlier idle title', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-hook') - transportFactoryQueue.push(transport) - enableActiveRuntimeEnvironment() - vi.useFakeTimers() - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'still working', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'claude', - paneKey, - stateHistory: [] - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - const statusHandler = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { - state: 'done' - prompt: string - agentType: 'claude' - lastAssistantMessage: string - }) => void) - | undefined - if (!titleHandler || !idleHandler || !statusHandler) { - throw new Error('Expected title, idle, and hook status handlers to be registered') - } - - titleHandler('Claude working', 'Claude working') - titleHandler('Claude done', 'Claude done') - idleHandler('Claude done') - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - - expect(deps.dispatchNotification).not.toHaveBeenCalled() - - statusHandler({ - state: 'done', - prompt: 'finish the implementation', - agentType: 'claude', - lastAssistantMessage: 'Done.' - }) - notifyStoreSubscribers() - expect(deps.setCacheTimerStartedAt).not.toHaveBeenCalledWith(paneKey, expect.any(Number)) - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS * 2) - - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'agent-task-complete', - paneKey, - agentStatusSnapshot: expect.objectContaining({ - state: 'done', - agentType: 'claude', - lastAssistantMessage: 'Done.' - }) - }) - ) - expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, expect.any(Number)) - expect(pane.terminal.write).toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - expect(storeSubscribers).toHaveLength(1) - }) - - it('applies accepted hook side effects when every completion alert consumer is disabled', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-hook') - transportFactoryQueue.push(transport) - enableActiveRuntimeEnvironment() - mockStoreState.ptyIdsByTabId = { 'tab-1': ['pty-hook'] } - mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { [LEAF_1]: 'pty-hook' } - vi.useFakeTimers() - mockStoreState.settings = { - ...mockStoreState.settings, - experimentalTerminalAttention: false, - notifications: { - enabled: true, - agentTaskComplete: false, - terminalBell: true, - suppressWhenFocused: false, - customSoundPath: null - } - } - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - const statusHandler = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { - state: 'working' | 'done' - prompt: string - agentType: 'claude' - lastAssistantMessage?: string - }) => void) - | undefined - if (!idleHandler || !statusHandler) { - throw new Error('Expected idle and hook status handlers to be registered') - } - - statusHandler({ - state: 'working', - prompt: 'finish the implementation', - agentType: 'claude' - }) - idleHandler('Claude done') - - expect(deps.setCacheTimerStartedAt).not.toHaveBeenCalledWith(paneKey, expect.any(Number)) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - - statusHandler({ - state: 'done', - prompt: 'finish the implementation', - agentType: 'claude', - lastAssistantMessage: 'Done.' - }) - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - - expect(deps.dispatchNotification).not.toHaveBeenCalled() - expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, expect.any(Number)) - expect(pane.terminal.write).toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - }) - - it.each([ - { - name: 'delivers confirmed process exit despite the same stale working hook row', - hookUpdateBeforeDispatch: 'none' - }, - { - name: 'delivers confirmed process exit after a same-turn working hook refresh', - hookUpdateBeforeDispatch: 'same-turn' - }, - { - name: 'delivers confirmed process exit after same-turn hook identity becomes known', - hookUpdateBeforeDispatch: 'same-turn-known-agent' - }, - { - name: 'cancels confirmed process exit delivery when a newer working hook row arrives', - hookUpdateBeforeDispatch: 'new-turn' - } - ] as const)('$name', async ({ hookUpdateBeforeDispatch }) => { - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-crashed-codex') - transportFactoryQueue.push(transport) - vi.useFakeTimers() - // Pin process cadence so confirmed exit precedes the hook refresh. - const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5) - - try { - const paneKey = makePaneKey('tab-1', LEAF_1) - const crashedTurnStartedAt = Date.now() - const initialAgentType = - hookUpdateBeforeDispatch === 'same-turn-known-agent' ? 'unknown' : 'codex' - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'crash before done hook', - updatedAt: crashedTurnStartedAt, - stateStartedAt: crashedTurnStartedAt, - agentType: initialAgentType, - paneKey, - stateHistory: [] - } - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - - titleHandler('Codex working', 'Codex working') - await vi.advanceTimersByTimeAsync(2_500) - getForegroundProcess.mockResolvedValue(null) - await vi.advanceTimersByTimeAsync(3_000) - if (hookUpdateBeforeDispatch !== 'none') { - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: - hookUpdateBeforeDispatch === 'new-turn' - ? 'new turn after the prior process exited' - : 'same turn hook detail refresh', - updatedAt: Date.now(), - stateStartedAt: - hookUpdateBeforeDispatch === 'new-turn' ? Date.now() : crashedTurnStartedAt, - agentType: 'codex', - paneKey, - stateHistory: [] - } - notifyStoreSubscribers() - } - await vi.advanceTimersByTimeAsync(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - - const expectedNotification = { - source: 'agent-task-complete', - terminalTitle: 'codex', - paneKey, - ptyId: 'tab-pty', - agentCompletionSource: 'process-exit' - } - if (hookUpdateBeforeDispatch === 'new-turn') { - expect(deps.dispatchNotification).not.toHaveBeenCalledWith(expectedNotification) - } else { - expect(deps.dispatchNotification).toHaveBeenCalledWith(expectedNotification) - } - expect(pane.terminal.write).toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - transport.sendInput.mockClear() - sendTerminalInputThroughPane(pane, '\x1b[I') - sendTerminalInputThroughPane(pane, '\x7f') - expect(transport.sendInput).toHaveBeenCalledTimes(2) - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[I', 'query-reply') - expect(transport.sendInput).toHaveBeenLastCalledWith('\x7f', 'query-reply') - } finally { - randomSpy.mockRestore() - restoreUserAgent() - } - }) - - it('drops an exited agent completion when a replacement agent hook row is active', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-replaced-codex') - transportFactoryQueue.push(transport) - vi.useFakeTimers() - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - - titleHandler('Codex working', 'Codex working') - await vi.advanceTimersByTimeAsync(2_500) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'replacement agent turn', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'claude', - paneKey, - stateHistory: [] - } - getForegroundProcess.mockResolvedValue('claude') - await vi.advanceTimersByTimeAsync(1_000) - await vi.advanceTimersByTimeAsync(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'replacement agent turn', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'claude', - paneKey, - stateHistory: [] - } - titleHandler('Claude done', 'Claude done') - await vi.advanceTimersByTimeAsync(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'agent-task-complete', - terminalTitle: 'Claude done', - paneKey - }) - ) - }) - - it('drops confirmed idle exit when a different hook owner appears between null samples', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-replaced-codex') - transportFactoryQueue.push(transport) - vi.useFakeTimers() - // Why: pin poll jitter so follow-up null samples cannot overtake replacement ownership. - const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5) - try { - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - // Why: one timer advance can start multiple reads; gate follow-up null samples until the replacement hook owner is installed. - let idleMode = false - let nullSamplesStarted = 0 - let releaseConfirmingNullSample: (() => void) | undefined - const confirmingNullSampleGate = new Promise((resolve) => { - releaseConfirmingNullSample = resolve - }) - getForegroundProcess.mockImplementation(async () => { - if (!idleMode) { - return 'codex' - } - nullSamplesStarted += 1 - if (nullSamplesStarted >= 2) { - await confirmingNullSampleGate - } - return null - }) - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!titleHandler || !idleHandler) { - throw new Error('Expected title and idle handlers to be registered') - } - - titleHandler('Codex working', 'Codex working') - await vi.advanceTimersByTimeAsync(2_500) - idleMode = true - for (let attempts = 0; nullSamplesStarted < 1; attempts += 1) { - if (attempts >= 10) { - throw new Error('Expected the first idle process inspection') - } - await vi.advanceTimersToNextTimerAsync() - } - // Why: let the first null sample record a pending process exit before the replacement owner is installed. - await flushAsyncTicks() - - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'replacement agent turn', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'claude', - paneKey, - stateHistory: [] - } - releaseConfirmingNullSample?.() - // Ignore cursor resets from setup so this assertion only covers the replacement idle event. - pane.terminal.write.mockClear() - idleHandler('Claude done') - await vi.advanceTimersByTimeAsync(800) - await vi.advanceTimersByTimeAsync(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - } finally { - // Why: an assertion failure above must not leave Math.random pinned for later tests. - randomSpy.mockRestore() - } - }) - - it('preserves replacement-agent title side effects through the process replacement veto', async () => { - const { dispatchAgentHookTerminalLifecycle } = await import('./agent-hook-terminal-lifecycle') - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-replaced-codex') - transportFactoryQueue.push(transport) - vi.useFakeTimers() - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!titleHandler || !idleHandler) { - throw new Error('Expected title and idle handlers to be registered') - } - - titleHandler('Codex working', 'Codex working') - await vi.advanceTimersByTimeAsync(2_500) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'replacement agent turn', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'claude', - paneKey, - stateHistory: [] - } - idleHandler('Claude done') - getForegroundProcess.mockResolvedValue('claude') - await vi.advanceTimersByTimeAsync(1_000) - - dispatchAgentHookTerminalLifecycle(paneKey, { - state: 'done', - prompt: 'replacement agent turn', - agentType: 'claude', - lastAssistantMessage: 'Done.' - }) - - expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, expect.any(Number)) - expect(pane.terminal.write).toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts new file mode 100644 index 00000000000..eba5c56bee1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts @@ -0,0 +1,16 @@ +import { describe } from 'vitest' +import { registerPtyConnectionKittyKeyboardRestoreTests } from './pty-connection-kitty-keyboard-restore.spec' +import { registerPtyConnectionMode2031SubscriptionsTests } from './pty-connection-mode-2031-subscriptions.spec' +import { registerPtyConnectionReattachModeResetTests } from './pty-connection-reattach-mode-reset.spec' +import { registerPtyConnectionWindowsKeyboardResetTests } from './pty-connection-windows-keyboard-reset.spec' +import { registerPtyConnectionWindowsCjkRepaintTests } from './pty-connection-windows-cjk-repaint.spec' +import { registerPtyConnectionRendererRiskRepaintTests } from './pty-connection-renderer-risk-repaint.spec' +import { registerPtyConnectionTerminalInputGatingTests } from './pty-connection-terminal-input-gating.spec' + +describe('pty-connection-kitty-keyboard-restore', registerPtyConnectionKittyKeyboardRestoreTests) +describe('pty-connection-mode-2031-subscriptions', registerPtyConnectionMode2031SubscriptionsTests) +describe('pty-connection-reattach-mode-reset', registerPtyConnectionReattachModeResetTests) +describe('pty-connection-windows-keyboard-reset', registerPtyConnectionWindowsKeyboardResetTests) +describe('pty-connection-windows-cjk-repaint', registerPtyConnectionWindowsCjkRepaintTests) +describe('pty-connection-renderer-risk-repaint', registerPtyConnectionRendererRiskRepaintTests) +describe('pty-connection-terminal-input-gating', registerPtyConnectionTerminalInputGatingTests) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.spec.ts new file mode 100644 index 00000000000..46187edddcb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.spec.ts @@ -0,0 +1,749 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { createKeyboardEventTarget, keyEvent } from './pty-connection-test-dom' +import { + LEAF_1, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionInterruptInferenceTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it.each(['custom-agent', 'codex'] as const)( + 'handles focused Ctrl+C for %s without guessing Codex cancellation', + async (agentType) => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop this task', + updatedAt: 1_000, + stateStartedAt: 900, + agentType, + paneKey, + terminalTitle: 'Codex', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const unrelatedTarget = createKeyboardEventTarget() + ;( + globalThis.window as unknown as { addEventListener?: ReturnType } + ).addEventListener = vi.fn() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + unrelatedTarget.dispatch({ + key: 'c', + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + repeat: false + } as KeyboardEvent) + vi.advanceTimersByTime(500) + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(globalThis.window.addEventListener).not.toHaveBeenCalled() + + terminalTarget.dispatch({ + key: 'c', + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + repeat: false + } as KeyboardEvent) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + + if (agentType === 'codex') { + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'working' }) + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'query-reply') + return + } + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop this task', + baselineAgentType: agentType, + intent: 'ctrl-c' + }) + } + ) + + it('clears stale working pane title after inferred interrupt applies', async () => { + const { connectPanePty } = await import('./pty-connection') + const { resolveWorktreeStatus } = await import('@/lib/worktree-status') + vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true) + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop visible spinner', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'custom-agent', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + const deps = createDeps({ + setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => { + mockStoreState.runtimePaneTitlesByTabId = { + ...mockStoreState.runtimePaneTitlesByTabId, + [tabId]: { + ...mockStoreState.runtimePaneTitlesByTabId[tabId], + [paneId]: title + } + } + }) + }) + + connectPanePty(pane as never, manager as never, deps as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Terminal') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Terminal') + expect( + resolveWorktreeStatus({ + tabs: [{ id: 'tab-1', title: 'Codex working' }], + browserTabs: [], + ptyIdsByTabId: { 'tab-1': ['tab-pty'] }, + runtimePaneTitlesByTabId: mockStoreState.runtimePaneTitlesByTabId, + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: true, + hasRetainedDone: false + }) + ).toBe('done') + }) + + it('clears unchanged title-only working indicators after acknowledged interrupt input', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + const deps = createDeps({ + setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => { + mockStoreState.runtimePaneTitlesByTabId = { + ...mockStoreState.runtimePaneTitlesByTabId, + [tabId]: { + ...mockStoreState.runtimePaneTitlesByTabId[tabId], + [paneId]: title + } + } + }) + }) + + connectPanePty(pane as never, manager as never, deps as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Terminal') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Terminal') + }) + + it('does not clear title-only working indicators when interrupt writes are unacknowledged', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + delete transport.sendInputAccepted + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const deps = createDeps() + + connectPanePty(pane as never, createManager(1) as never, deps as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(transport.sendInput).toHaveBeenCalledWith('\x03', 'query-reply') + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() + expect(deps.updateTabTitle).not.toHaveBeenCalled() + }) + + it('infers exact Ctrl+C terminal input when keydown capture misses the press', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop from real terminal byte', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'custom-agent', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop from real terminal byte', + baselineAgentType: 'custom-agent', + intent: 'ctrl-c' + }) + }) + + it('marks bracketed paste as stale after acknowledged Ctrl+C input', async () => { + const { connectPanePty } = await import('./pty-connection') + const { pasteTerminalText } = await import('./terminal-bracketed-paste') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.modes.bracketedPasteMode = true + const observedIgnoreValues: (boolean | undefined)[] = [] + pane.terminal.paste.mockImplementation(() => { + observedIgnoreValues.push(pane.terminal.options.ignoreBracketedPasteMode) + }) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + pasteTerminalText(pane.terminal as never, 'a69ce28e1d092e0c8825cd1a109ac36409962bc1') + + expect(observedIgnoreValues).toEqual([true]) + expect(pane.terminal.options.ignoreBracketedPasteMode).toBe(false) + }) + + it('infers captured Ctrl+C even when xterm emits an enhanced keyboard sequence', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop enhanced keyboard input', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'custom-agent', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u', 'query-reply') + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop enhanced keyboard input', + baselineAgentType: 'custom-agent', + intent: 'ctrl-c' + }) + }) + + it('infers interrupt for an explicit working status even if the title is already non-agent', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop after process exit', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'custom-agent', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u') + await flushAsyncTicks() + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Terminal 1' + } + } + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop after process exit', + baselineAgentType: 'custom-agent', + intent: 'ctrl-c' + }) + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + }) + + it('keeps inferred interrupted status after the pane settles on a non-agent title', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop and leave shell', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'custom-agent', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + vi.mocked(window.api.agentStatus.inferInterrupt).mockImplementation(async () => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'stop and leave shell', + interrupted: true, + updatedAt: 1_100, + stateStartedAt: 1_100, + agentType: 'custom-agent', + paneKey, + terminalTitle: 'Terminal 1' + } + return true + }) + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const deps = createDeps({ + setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => { + mockStoreState.runtimePaneTitlesByTabId = { + ...mockStoreState.runtimePaneTitlesByTabId, + [tabId]: { + ...mockStoreState.runtimePaneTitlesByTabId[tabId], + [paneId]: title + } + } + }) + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + + vi.advanceTimersByTime(750) + await flushAsyncTicks() + + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + }) + + it('does not infer exact interrupt input when the transport cannot acknowledge writes', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + delete transport.sendInputAccepted + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'ssh style write', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as unknown as (data: string) => void)('\x03') + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(transport.sendInput).toHaveBeenCalledWith('\x03', 'query-reply') + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + }) + + it('removes agent status and pane title on PTY exit after inferred interrupt', async () => { + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true) + const transport = createMockTransport('tab-pty') + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop then exit', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const deps = createDeps() + + connectPanePty(pane as never, createManager(1) as never, deps as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + if (!onPtyExit) { + throw new Error('expected onPtyExit callback to be registered') + } + + onPtyExit('tab-pty') + + expect(deps.clearRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1) + expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'tab-pty') + expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, null) + expect(mockStoreState.removeAgentStatus).toHaveBeenCalledWith(paneKey) + }) + + it('ignores repeated and modified terminal interrupt keydowns', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'keep running', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + for (const event of [ + { + key: 'Escape', + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + repeat: true + }, + { + key: 'Escape', + ctrlKey: false, + metaKey: false, + altKey: true, + shiftKey: false, + repeat: false + }, + { + key: 'c', + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: true, + repeat: false + } + ]) { + terminalTarget.dispatch(event as KeyboardEvent) + } + vi.advanceTimersByTime(500) + + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + }) + + it('does not infer Ctrl+C when terminal selection turns the chord into copy', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'copy selection', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown; hasSelection: ReturnType }).element = + terminalTarget.target + ;(pane.terminal as { hasSelection: ReturnType }).hasSelection.mockReturnValue( + true + ) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + vi.advanceTimersByTime(500) + + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + }) + + it('removes the terminal key listener on dispose before a remount adds another', async () => { + const { connectPanePty } = await import('./pty-connection') + const firstTransport = createMockTransport() + const secondTransport = createMockTransport() + transportFactoryQueue.push(firstTransport, secondTransport) + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + + const firstConnection = connectPanePty( + pane as never, + createManager(1) as never, + createDeps() as never + ) + expect(terminalTarget.handlers.size).toBe(1) + + firstConnection.dispose() + expect(terminalTarget.handlers.size).toBe(0) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + expect(terminalTarget.handlers.size).toBe(1) + expect( + terminalTarget.target.addEventListener.mock.calls.filter(([type]) => type === 'keydown') + ).toHaveLength(2) + expect( + terminalTarget.target.removeEventListener.mock.calls.filter(([type]) => type === 'keydown') + ).toHaveLength(1) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts deleted file mode 100644 index abe8e7d3449..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts +++ /dev/null @@ -1,838 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { createKeyboardEventTarget, keyEvent } from './pty-connection-test-dom' -import { - LEAF_1, - createMockTransport, - createPane, - createManager -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import type { MockTransport } from './pty-connection-test-pane-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it.each(['custom-agent', 'codex'] as const)( - 'handles focused Ctrl+C for %s without guessing Codex cancellation', - async (agentType) => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'stop this task', - updatedAt: 1_000, - stateStartedAt: 900, - agentType, - paneKey, - terminalTitle: 'Codex', - stateHistory: [] - } - const terminalTarget = createKeyboardEventTarget() - const unrelatedTarget = createKeyboardEventTarget() - ;( - globalThis.window as unknown as { addEventListener?: ReturnType } - ).addEventListener = vi.fn() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - unrelatedTarget.dispatch({ - key: 'c', - ctrlKey: true, - metaKey: false, - altKey: false, - shiftKey: false, - repeat: false - } as KeyboardEvent) - vi.advanceTimersByTime(500) - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - expect(globalThis.window.addEventListener).not.toHaveBeenCalled() - - terminalTarget.dispatch({ - key: 'c', - ctrlKey: true, - metaKey: false, - altKey: false, - shiftKey: false, - repeat: false - } as KeyboardEvent) - ;(onDataHandler as unknown as (data: string) => void)('\x03') - await flushAsyncTicks() - vi.advanceTimersByTime(500) - - if (agentType === 'codex') { - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'working' }) - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'query-reply') - return - } - expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ - paneKey, - baselineUpdatedAt: 1_000, - baselineStateStartedAt: 900, - baselinePrompt: 'stop this task', - baselineAgentType: agentType, - intent: 'ctrl-c' - }) - } - ) - - it('clears stale working pane title after inferred interrupt applies', async () => { - const { connectPanePty } = await import('./pty-connection') - const { resolveWorktreeStatus } = await import('@/lib/worktree-status') - vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true) - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.runtimePaneTitlesByTabId = { - 'tab-1': { - 1: 'Codex working' - } - } - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'stop visible spinner', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'custom-agent', - paneKey, - terminalTitle: 'Codex working', - stateHistory: [] - } - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - manager.getActivePane.mockReturnValue({ id: 1 }) - const deps = createDeps({ - setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => { - mockStoreState.runtimePaneTitlesByTabId = { - ...mockStoreState.runtimePaneTitlesByTabId, - [tabId]: { - ...mockStoreState.runtimePaneTitlesByTabId[tabId], - [paneId]: title - } - } - }) - }) - - connectPanePty(pane as never, manager as never, deps as never) - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - ;(onDataHandler as unknown as (data: string) => void)('\x03') - await flushAsyncTicks() - vi.advanceTimersByTime(500) - await flushAsyncTicks() - - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Terminal') - expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Terminal') - expect( - resolveWorktreeStatus({ - tabs: [{ id: 'tab-1', title: 'Codex working' }], - browserTabs: [], - ptyIdsByTabId: { 'tab-1': ['tab-pty'] }, - runtimePaneTitlesByTabId: mockStoreState.runtimePaneTitlesByTabId, - hasPermission: false, - hasLiveWorking: false, - hasLiveDone: true, - hasRetainedDone: false - }) - ).toBe('done') - }) - - it('clears unchanged title-only working indicators after acknowledged interrupt input', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - mockStoreState.runtimePaneTitlesByTabId = { - 'tab-1': { - 1: 'Codex working' - } - } - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - manager.getActivePane.mockReturnValue({ id: 1 }) - const deps = createDeps({ - setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => { - mockStoreState.runtimePaneTitlesByTabId = { - ...mockStoreState.runtimePaneTitlesByTabId, - [tabId]: { - ...mockStoreState.runtimePaneTitlesByTabId[tabId], - [paneId]: title - } - } - }) - }) - - connectPanePty(pane as never, manager as never, deps as never) - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - ;(onDataHandler as unknown as (data: string) => void)('\x03') - await flushAsyncTicks() - vi.advanceTimersByTime(500) - await flushAsyncTicks() - - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Terminal') - expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Terminal') - }) - - it('does not clear title-only working indicators when interrupt writes are unacknowledged', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - delete transport.sendInputAccepted - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - mockStoreState.runtimePaneTitlesByTabId = { - 'tab-1': { - 1: 'Codex working' - } - } - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const deps = createDeps() - - connectPanePty(pane as never, createManager(1) as never, deps as never) - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - ;(onDataHandler as unknown as (data: string) => void)('\x03') - await flushAsyncTicks() - vi.advanceTimersByTime(500) - await flushAsyncTicks() - - expect(transport.sendInput).toHaveBeenCalledWith('\x03', 'query-reply') - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() - expect(deps.updateTabTitle).not.toHaveBeenCalled() - }) - - it('infers exact Ctrl+C terminal input when keydown capture misses the press', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'stop from real terminal byte', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'custom-agent', - paneKey, - terminalTitle: 'Codex working', - stateHistory: [] - } - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as unknown as (data: string) => void)('\x03') - await flushAsyncTicks() - vi.advanceTimersByTime(500) - await flushAsyncTicks() - - expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ - paneKey, - baselineUpdatedAt: 1_000, - baselineStateStartedAt: 900, - baselinePrompt: 'stop from real terminal byte', - baselineAgentType: 'custom-agent', - intent: 'ctrl-c' - }) - }) - - it('marks bracketed paste as stale after acknowledged Ctrl+C input', async () => { - const { connectPanePty } = await import('./pty-connection') - const { pasteTerminalText } = await import('./terminal-bracketed-paste') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const pane = createPane(1) - pane.terminal.modes.bracketedPasteMode = true - const observedIgnoreValues: (boolean | undefined)[] = [] - pane.terminal.paste.mockImplementation(() => { - observedIgnoreValues.push(pane.terminal.options.ignoreBracketedPasteMode) - }) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as unknown as (data: string) => void)('\x03') - await flushAsyncTicks() - pasteTerminalText(pane.terminal as never, 'a69ce28e1d092e0c8825cd1a109ac36409962bc1') - - expect(observedIgnoreValues).toEqual([true]) - expect(pane.terminal.options.ignoreBracketedPasteMode).toBe(false) - }) - - it('infers captured Ctrl+C even when xterm emits an enhanced keyboard sequence', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'stop enhanced keyboard input', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'custom-agent', - paneKey, - terminalTitle: 'Codex working', - stateHistory: [] - } - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - ;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u') - await flushAsyncTicks() - vi.advanceTimersByTime(500) - await flushAsyncTicks() - - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u', 'query-reply') - expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ - paneKey, - baselineUpdatedAt: 1_000, - baselineStateStartedAt: 900, - baselinePrompt: 'stop enhanced keyboard input', - baselineAgentType: 'custom-agent', - intent: 'ctrl-c' - }) - }) - - it('infers interrupt for an explicit working status even if the title is already non-agent', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.runtimePaneTitlesByTabId = { - 'tab-1': { - 1: 'Codex working' - } - } - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'stop after process exit', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'custom-agent', - paneKey, - terminalTitle: 'Codex working', - stateHistory: [] - } - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - ;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u') - await flushAsyncTicks() - mockStoreState.runtimePaneTitlesByTabId = { - 'tab-1': { - 1: 'Terminal 1' - } - } - vi.advanceTimersByTime(500) - await flushAsyncTicks() - - expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ - paneKey, - baselineUpdatedAt: 1_000, - baselineStateStartedAt: 900, - baselinePrompt: 'stop after process exit', - baselineAgentType: 'custom-agent', - intent: 'ctrl-c' - }) - expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - }) - - it('keeps inferred interrupted status after the pane settles on a non-agent title', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.runtimePaneTitlesByTabId = { - 'tab-1': { - 1: 'Codex working' - } - } - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'stop and leave shell', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'custom-agent', - paneKey, - terminalTitle: 'Codex working', - stateHistory: [] - } - vi.mocked(window.api.agentStatus.inferInterrupt).mockImplementation(async () => { - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'stop and leave shell', - interrupted: true, - updatedAt: 1_100, - stateStartedAt: 1_100, - agentType: 'custom-agent', - paneKey, - terminalTitle: 'Terminal 1' - } - return true - }) - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const deps = createDeps({ - setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => { - mockStoreState.runtimePaneTitlesByTabId = { - ...mockStoreState.runtimePaneTitlesByTabId, - [tabId]: { - ...mockStoreState.runtimePaneTitlesByTabId[tabId], - [paneId]: title - } - } - }) - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - ;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u') - await flushAsyncTicks() - vi.advanceTimersByTime(500) - await flushAsyncTicks() - expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalled() - expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - - vi.advanceTimersByTime(750) - await flushAsyncTicks() - - expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - }) - - it('does not infer exact interrupt input when the transport cannot acknowledge writes', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - delete transport.sendInputAccepted - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'ssh style write', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'codex', - paneKey, - terminalTitle: 'Codex working', - stateHistory: [] - } - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as unknown as (data: string) => void)('\x03') - vi.advanceTimersByTime(500) - await flushAsyncTicks() - - expect(transport.sendInput).toHaveBeenCalledWith('\x03', 'query-reply') - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - }) - - it('removes agent status and pane title on PTY exit after inferred interrupt', async () => { - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true) - const transport = createMockTransport('tab-pty') - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.runtimePaneTitlesByTabId = { - 'tab-1': { - 1: 'Codex working' - } - } - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'stop then exit', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'codex', - paneKey, - terminalTitle: 'Codex working', - stateHistory: [] - } - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const deps = createDeps() - - connectPanePty(pane as never, createManager(1) as never, deps as never) - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - ;(onDataHandler as unknown as (data: string) => void)('\x03') - await flushAsyncTicks() - vi.advanceTimersByTime(500) - await flushAsyncTicks() - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - if (!onPtyExit) { - throw new Error('expected onPtyExit callback to be registered') - } - - onPtyExit('tab-pty') - - expect(deps.clearRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1) - expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'tab-pty') - expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, null) - expect(mockStoreState.removeAgentStatus).toHaveBeenCalledWith(paneKey) - }) - - it('ignores repeated and modified terminal interrupt keydowns', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'keep running', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'codex', - paneKey, - terminalTitle: 'Codex', - stateHistory: [] - } - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - for (const event of [ - { - key: 'Escape', - ctrlKey: false, - metaKey: false, - altKey: false, - shiftKey: false, - repeat: true - }, - { - key: 'Escape', - ctrlKey: false, - metaKey: false, - altKey: true, - shiftKey: false, - repeat: false - }, - { - key: 'c', - ctrlKey: true, - metaKey: false, - altKey: false, - shiftKey: true, - repeat: false - } - ]) { - terminalTarget.dispatch(event as KeyboardEvent) - } - vi.advanceTimersByTime(500) - - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - }) - - it('does not infer Ctrl+C when terminal selection turns the chord into copy', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'copy selection', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'codex', - paneKey, - terminalTitle: 'Codex', - stateHistory: [] - } - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown; hasSelection: ReturnType }).element = - terminalTarget.target - ;(pane.terminal as { hasSelection: ReturnType }).hasSelection.mockReturnValue( - true - ) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - vi.advanceTimersByTime(500) - - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - }) - - it('removes the terminal key listener on dispose before a remount adds another', async () => { - const { connectPanePty } = await import('./pty-connection') - const firstTransport = createMockTransport() - const secondTransport = createMockTransport() - transportFactoryQueue.push(firstTransport, secondTransport) - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - - const firstConnection = connectPanePty( - pane as never, - createManager(1) as never, - createDeps() as never - ) - expect(terminalTarget.handlers.size).toBe(1) - - firstConnection.dispose() - expect(terminalTarget.handlers.size).toBe(0) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - expect(terminalTarget.handlers.size).toBe(1) - expect( - terminalTarget.target.addEventListener.mock.calls.filter(([type]) => type === 'keydown') - ).toHaveLength(2) - expect( - terminalTarget.target.removeEventListener.mock.calls.filter(([type]) => type === 'keydown') - ).toHaveLength(1) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.spec.ts new file mode 100644 index 00000000000..9e8ca0e2b7c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.spec.ts @@ -0,0 +1,145 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + POST_REPLAY_REATTACH_RESET, + PROCESS_BOUNDARY_GROUND +} from '../../../../shared/terminal-mode-reset-profiles' +import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' +import { replayEpilogue } from './pty-connection-test-replay-epilogue' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionKittyKeyboardRestoreTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + // Why: xterm and the pane mirror must end every restore on the same kitty flags. + describe('connectPanePty kitty keyboard restore', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + async function reattachWithSnapshotFlags(kittyKeyboard: boolean) { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'live app frame', + snapshotKittyKeyboardFlags: 31, + snapshotSeq: 7 + } + : null + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.options.vtExtensions.kittyKeyboard = kittyKeyboard + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pane-connection mocks cover the members connectPanePty reads. + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + return { pane, mirror: deps.paneKittyKeyboardModesRef.current.get(pane.id) } + } + + it("restores a live app's host-proven flags into xterm and the mirror on reattach", async () => { + const { pane, mirror } = await reattachWithSnapshotFlags(true) + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${POST_REPLAY_REATTACH_RESET}\x1b[<99u\x1b[=31u`, + expect.any(Function) + ) + expect(mirror?.flags).toBe(31) + }) + + // Why: xterm ignores CSI u while the protocol is withheld (ConPTY), so its mirror must too. + it('keeps a withheld-protocol pane at 0 in both records despite host flags', async () => { + const { pane, mirror } = await reattachWithSnapshotFlags(false) + + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_REATTACH_RESET), + expect.any(Function) + ) + expect(mirror?.flags).toBe(0) + }) + + it('grounds xterm and the mirror with one scanned write before a restart-in-place spawns', async () => { + const { connectPanePty } = await import('./pty-connection') + transportFactoryQueue.push(createMockTransport()) + const pane = createPane(91) + // Why: a unique tab id keeps this pane's key clear of other tests' pendingSpawnByPaneKey entries so the connect deterministically fresh-spawns. + const deps = createDeps({ tabId: 'tab-kitty-fresh-spawn' }) + // The pane's previous occupant left the alternate screen and kitty flags on. + const mirror = new TerminalKittyKeyboardModeTracker() + mirror.scan('\x1b[?1049h\x1b[>5u') + deps.paneKittyKeyboardModesRef.current.set(pane.id, mirror) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pane-connection mocks cover the members connectPanePty reads. + connectPanePty(pane as never, createManager(91) as never, deps as never) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + PROCESS_BOUNDARY_GROUND, + expect.any(Function) + ) + expect(mirror.snapshotFlags).toBe(0) + expect(mirror.isAlternateScreen).toBe(false) + }) + + it('grounds xterm and the mirror together on Reset Terminal and asks the host to ground', async () => { + const { pane, mirror } = await reattachWithSnapshotFlags(true) + expect(mirror?.flags).toBe(31) + const { resetTerminalInputModes } = await import('./terminal-input-mode-reset') + + resetTerminalInputModes('tab-pty') + + expect(pane.terminal.write).toHaveBeenLastCalledWith( + PROCESS_BOUNDARY_GROUND, + expect.any(Function) + ) + expect(mirror?.flags).toBe(0) + expect(window.api.pty.resetInputModes).toHaveBeenCalledWith('tab-pty') + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts deleted file mode 100644 index cfbb806bb99..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - POST_REPLAY_REATTACH_RESET, - PROCESS_BOUNDARY_GROUND -} from '../../../../shared/terminal-mode-reset-profiles' -import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' -import { replayEpilogue } from './pty-connection-test-replay-epilogue' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -// Why: xterm and the pane mirror must end every restore on the same kitty flags. -describe('connectPanePty kitty keyboard restore', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - async function reattachWithSnapshotFlags(kittyKeyboard: boolean) { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => - sessionId - ? { - id: sessionId, - snapshot: 'live app frame', - snapshotKittyKeyboardFlags: 31, - snapshotSeq: 7 - } - : null - ) - transportFactoryQueue.push(transport) - const pane = createPane(1) - pane.terminal.options.vtExtensions.kittyKeyboard = kittyKeyboard - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pane-connection mocks cover the members connectPanePty reads. - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - return { pane, mirror: deps.paneKittyKeyboardModesRef.current.get(pane.id) } - } - - it("restores a live app's host-proven flags into xterm and the mirror on reattach", async () => { - const { pane, mirror } = await reattachWithSnapshotFlags(true) - - expect(pane.terminal.write).toHaveBeenCalledWith( - `${POST_REPLAY_REATTACH_RESET}\x1b[<99u\x1b[=31u`, - expect.any(Function) - ) - expect(mirror?.flags).toBe(31) - }) - - // Why: xterm ignores CSI u while the protocol is withheld (ConPTY), so its mirror must too. - it('keeps a withheld-protocol pane at 0 in both records despite host flags', async () => { - const { pane, mirror } = await reattachWithSnapshotFlags(false) - - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_REATTACH_RESET), - expect.any(Function) - ) - expect(mirror?.flags).toBe(0) - }) - - it('grounds xterm and the mirror with one scanned write before a restart-in-place spawns', async () => { - const { connectPanePty } = await import('./pty-connection') - transportFactoryQueue.push(createMockTransport()) - const pane = createPane(91) - // Why: a unique tab id keeps this pane's key clear of other tests' pendingSpawnByPaneKey entries so the connect deterministically fresh-spawns. - const deps = createDeps({ tabId: 'tab-kitty-fresh-spawn' }) - // The pane's previous occupant left the alternate screen and kitty flags on. - const mirror = new TerminalKittyKeyboardModeTracker() - mirror.scan('\x1b[?1049h\x1b[>5u') - deps.paneKittyKeyboardModesRef.current.set(pane.id, mirror) - - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pane-connection mocks cover the members connectPanePty reads. - connectPanePty(pane as never, createManager(91) as never, deps as never) - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith(PROCESS_BOUNDARY_GROUND, expect.any(Function)) - expect(mirror.snapshotFlags).toBe(0) - expect(mirror.isAlternateScreen).toBe(false) - }) - - it('grounds xterm and the mirror together on Reset Terminal and asks the host to ground', async () => { - const { pane, mirror } = await reattachWithSnapshotFlags(true) - expect(mirror?.flags).toBe(31) - const { resetTerminalInputModes } = await import('./terminal-input-mode-reset') - - resetTerminalInputModes('tab-pty') - - expect(pane.terminal.write).toHaveBeenLastCalledWith( - PROCESS_BOUNDARY_GROUND, - expect.any(Function) - ) - expect(mirror?.flags).toBe(0) - expect(window.api.pty.resetInputModes).toHaveBeenCalledWith('tab-pty') - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.spec.ts new file mode 100644 index 00000000000..29a772f9a9d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.spec.ts @@ -0,0 +1,596 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './pty-connection-test-constants' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { + createInitialStoreState, + buildActiveRuntimeEnvironmentState +} from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionMainSideEffectAuthorityTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + // Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. + function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { + mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) + } + + function notifyStoreSubscribers(): void { + for (const listener of storeSubscribers.slice()) { + listener(mockStoreState) + } + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + describe('with main side-effect authority on', () => { + const SIDE_EFFECT_PARSER_CALLBACKS = [ + 'onTitleChange', + 'onBell', + 'onAgentBecameIdle', + 'onAgentBecameWorking', + 'onAgentExited' + ] as const + + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } + } + + it('omits byte-parser callbacks from the local transport options', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createdTransportOptions[0]).toBeDefined() + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeUndefined() + } + // Lifecycle callbacks stay on the transport; only side-effect parsing moves to the fact consumer. + expect(createdTransportOptions[0]?.onPtySpawn).toBeTypeOf('function') + expect(createdTransportOptions[0]?.onPtyExit).toBeTypeOf('function') + }) + + it('keeps byte-parser callbacks on remote-runtime transports', async () => { + enableMainAuthority() + enableActiveRuntimeEnvironment() + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') + } + }) + + it('consumes pty:sideEffect facts with the live-path policy after spawn', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + connectPanePty(pane as never, manager as never, deps as never) + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-1') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-1', + seq: 10, + facts: [ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }, + { kind: 'bell' } + ] + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex working') + expect(deps.markTerminalBell).toHaveBeenCalledTimes(1) + expect(deps.markTerminalBell).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId: 'tab-1', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + expect(deps.dispatchNotification).not.toHaveBeenCalled() + vi.advanceTimersByTime(250) + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'terminal-bell', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + ) + }) + + it('stops consuming facts after the pane binding is disposed', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const deps = createDeps() + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-2') + + binding.dispose() + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-2', + seq: 1, + facts: [{ kind: 'bell' }] + }) + + expect(deps.markTerminalBell).not.toHaveBeenCalled() + }) + + it('schedules the completion notification for genuine working→idle facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-genuine') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-genuine', + seq: 1, + facts: [ + { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, + { kind: 'agent-working' } + ] + }) + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-genuine', + seq: 2, + facts: [ + { kind: 'title', normalizedTitle: '* Codex done', rawTitle: '* Codex done' }, + { kind: 'agent-idle', title: '* Codex done' } + ] + }) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('clears state without completion attention for stale-derived facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-stale') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-stale', + seq: 1, + facts: [ + { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, + { kind: 'agent-working' } + ] + }) + // Main's unthrottled 3s stale-title rewrite for a merely-paused agent. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-stale', + seq: 2, + facts: [ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ] + }) + + // The cleared title still lands; the cache timer is cleared. + expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'Codex') + expect(deps.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + makePaneKey('tab-1', LEAF_1), + null + ) + // But no task-complete notification or unread attention is scheduled. + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS * 2) + expect(deps.dispatchNotification).not.toHaveBeenCalled() + expect(deps.markTerminalBell).not.toHaveBeenCalled() + }) + + it('drops the agent status from a command-finished fact like the byte path did', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-133') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-133', + seq: 1, + facts: [{ kind: 'command-finished', exitCode: 130 }] + }) + + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() + }) + + it('routes pr-link facts to the worktree PR observer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-pr') + + const link = { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-pr', + seq: 1, + facts: [{ kind: 'pr-link', link }] + }) + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( + 'wt-1', + link, + expect.objectContaining({ tabId: 'tab-1', ptyId: 'tab-pty' }) + ) + }) + + it('does not byte-scan PR links or OSC 133 — facts are the only consumer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-authority-bytes' + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('Created https://github.com/acme/orca/pull/42\r\n') + capturedDataCallback.current?.('\x1b]133;D;130\x07prompt $ ') + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + }) + + it('seeds and settles Command Code status from command-code facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-cc') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc', + seq: 1, + facts: [{ kind: 'command-code-working', prompt: 'say hi' }] + }) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'say hi', + agentType: 'command-code' + }) + + // Why: done is a hint — the settle timer stays in pane policy so it can consult the live status row before completing. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc', + seq: 2, + facts: [{ kind: 'command-code-done', prompt: 'say hi' }] + }) + vi.advanceTimersByTime(1499) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'working' }) + vi.advanceTimersByTime(1) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + prompt: 'say hi', + agentType: 'command-code' + }) + }) + + it('rejects Command Code facts when Claude owns the pane', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + const claudeStatus = { + paneKey, + state: 'done' as const, + prompt: 'Previous Claude turn', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude' as const, + stateHistory: [] + } + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-1', ptyId: null, launchAgent: 'claude' }] + } + mockStoreState.agentStatusByPaneKey[paneKey] = claudeStatus + mockStoreState.paneForegroundAgentByPaneKey[paneKey] = { + agent: 'claude', + shellForeground: false + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-false-cc') + mockStoreState.setAgentStatus.mockClear() + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-false-cc', + seq: 1, + facts: [ + { kind: 'command-code-working', prompt: 'False prompt' }, + { kind: 'command-code-done', prompt: 'False prompt' } + ] + }) + vi.advanceTimersByTime(2000) + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBe(claudeStatus) + }) + + it('rejects Command Code facts when retained Claude identity owns the pane', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + const unknownStatus = { + paneKey, + state: 'working' as const, + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'unknown' as const, + stateHistory: [] + } + mockStoreState.retainedAgentsByPaneKey[paneKey] = { agentType: 'claude' } + mockStoreState.agentStatusByPaneKey[paneKey] = unknownStatus + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-retained-false-cc') + mockStoreState.setAgentStatus.mockClear() + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-retained-false-cc', + seq: 1, + facts: [ + { kind: 'command-code-working', prompt: 'False prompt' }, + { kind: 'command-code-done', prompt: 'False prompt' } + ] + }) + vi.advanceTimersByTime(2000) + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBe(unknownStatus) + }) + + it('keeps Command Code working when a working fact lands before the done settles', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-cc-repaint') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 1, + facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] + }) + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 2, + facts: [{ kind: 'command-code-done', prompt: 'Run a slow command' }] + }) + vi.advanceTimersByTime(1000) + // An active repaint within the settle window cancels the pending done. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 3, + facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] + }) + vi.advanceTimersByTime(2000) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'Run a slow command', + agentType: 'command-code' + }) + }) + + it('does not byte-scan Command Code output — facts are the only consumer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-authority-cc-bytes' + } + ) + transportFactoryQueue.push(transport) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup: { command: 'command-code --trust' } }) as never + ) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('# Command Code v0.27.2\r\n') + capturedDataCallback.current?.('❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + }) + + it('honors the persisted kill switch for panes bound before settings hydrate', async () => { + // Pre-hydration: settings not loaded but kill switch persisted off — pane registers byte parsers, not a fact consumer. + mockStoreState.settings = null + ;(window.api as unknown as Record).settings = { + getSync: vi.fn(() => ({ terminalMainSideEffectAuthority: false })) + } + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') + } + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-prehydration') + + // No fact consumer registered: channel batches are dropped. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-prehydration', + seq: 1, + facts: [{ kind: 'bell' }] + }) + expect(deps.markTerminalBell).not.toHaveBeenCalled() + + // Hydration with switch still off: byte parsing stays the single consumer — one BEL marks unread once. + mockStoreState.settings = { terminalMainSideEffectAuthority: false } + notifyStoreSubscribers() + const onBell = createdTransportOptions[0]?.onBell as () => void + onBell() + expect(deps.markTerminalBell).toHaveBeenCalledTimes(1) + }) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts deleted file mode 100644 index dff4c5216a7..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts +++ /dev/null @@ -1,687 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './pty-connection-test-constants' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { - createInitialStoreState, - buildActiveRuntimeEnvironmentState -} from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. -function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { - mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) -} - -function notifyStoreSubscribers(): void { - for (const listener of storeSubscribers.slice()) { - listener(mockStoreState) - } -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - describe('with main side-effect authority on', () => { - const SIDE_EFFECT_PARSER_CALLBACKS = [ - 'onTitleChange', - 'onBell', - 'onAgentBecameIdle', - 'onAgentBecameWorking', - 'onAgentExited' - ] as const - - function enableMainAuthority(): void { - mockStoreState.settings = { - ...mockStoreState.settings, - terminalMainSideEffectAuthority: true - } - } - - it('omits byte-parser callbacks from the local transport options', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - - expect(createdTransportOptions[0]).toBeDefined() - for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { - expect(createdTransportOptions[0]?.[callback]).toBeUndefined() - } - // Lifecycle callbacks stay on the transport; only side-effect parsing moves to the fact consumer. - expect(createdTransportOptions[0]?.onPtySpawn).toBeTypeOf('function') - expect(createdTransportOptions[0]?.onPtyExit).toBeTypeOf('function') - }) - - it('keeps byte-parser callbacks on remote-runtime transports', async () => { - enableMainAuthority() - enableActiveRuntimeEnvironment() - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - - expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) - for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { - expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') - } - }) - - it('consumes pty:sideEffect facts with the live-path policy after spawn', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const handler = await import('./terminal-side-effect-facts-handler') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - connectPanePty(pane as never, manager as never, deps as never) - - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void - onPtySpawn('pty-fact-1') - - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-1', - seq: 10, - facts: [ - { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }, - { kind: 'bell' } - ] - }) - - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex working') - expect(deps.markTerminalBell).toHaveBeenCalledTimes(1) - expect(deps.markTerminalBell).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - tabId: 'tab-1', - paneKey: makePaneKey('tab-1', LEAF_1) - }) - expect(deps.dispatchNotification).not.toHaveBeenCalled() - vi.advanceTimersByTime(250) - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'terminal-bell', - paneKey: makePaneKey('tab-1', LEAF_1) - }) - ) - }) - - it('stops consuming facts after the pane binding is disposed', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const handler = await import('./terminal-side-effect-facts-handler') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - const deps = createDeps() - const binding = connectPanePty( - createPane(1) as never, - createManager(1) as never, - deps as never - ) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void - onPtySpawn('pty-fact-2') - - binding.dispose() - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-2', - seq: 1, - facts: [{ kind: 'bell' }] - }) - - expect(deps.markTerminalBell).not.toHaveBeenCalled() - }) - - it('schedules the completion notification for genuine working→idle facts', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const handler = await import('./terminal-side-effect-facts-handler') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - - const deps = createDeps() - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void - onPtySpawn('pty-fact-genuine') - - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-genuine', - seq: 1, - facts: [ - { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, - { kind: 'agent-working' } - ] - }) - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-genuine', - seq: 2, - facts: [ - { kind: 'title', normalizedTitle: '* Codex done', rawTitle: '* Codex done' }, - { kind: 'agent-idle', title: '* Codex done' } - ] - }) - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - }) - - it('clears state without completion attention for stale-derived facts', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const handler = await import('./terminal-side-effect-facts-handler') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - - const deps = createDeps() - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void - onPtySpawn('pty-fact-stale') - - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-stale', - seq: 1, - facts: [ - { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, - { kind: 'agent-working' } - ] - }) - // Main's unthrottled 3s stale-title rewrite for a merely-paused agent. - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-stale', - seq: 2, - facts: [ - { - kind: 'title', - normalizedTitle: 'Codex', - rawTitle: 'Codex', - staleWorkingTitleClear: true - }, - { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } - ] - }) - - // The cleared title still lands; the cache timer is cleared. - expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'Codex') - expect(deps.setCacheTimerStartedAt).toHaveBeenLastCalledWith( - makePaneKey('tab-1', LEAF_1), - null - ) - // But no task-complete notification or unread attention is scheduled. - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS * 2) - expect(deps.dispatchNotification).not.toHaveBeenCalled() - expect(deps.markTerminalBell).not.toHaveBeenCalled() - }) - - it('drops the agent status from a command-finished fact like the byte path did', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const handler = await import('./terminal-side-effect-facts-handler') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey = { - [paneKey]: { - paneKey, - state: 'done', - prompt: 'hi', - updatedAt: 1000, - stateStartedAt: 1000, - agentType: 'codex', - stateHistory: [] - } - } - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void - onPtySpawn('pty-fact-133') - - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-133', - seq: 1, - facts: [{ kind: 'command-finished', exitCode: 130 }] - }) - - expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) - expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() - }) - - it('routes pr-link facts to the worktree PR observer', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const handler = await import('./terminal-side-effect-facts-handler') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void - onPtySpawn('pty-fact-pr') - - const link = { - url: 'https://github.com/acme/orca/pull/42', - slug: { owner: 'acme', repo: 'orca' }, - number: 42 - } - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-pr', - seq: 1, - facts: [{ kind: 'pr-link', link }] - }) - - expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( - 'wt-1', - link, - expect.objectContaining({ tabId: 'tab-1', ptyId: 'tab-pty' }) - ) - }) - - it('does not byte-scan PR links or OSC 133 — facts are the only consumer', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-authority-bytes' - } - ) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey = { - [paneKey]: { - paneKey, - state: 'done', - prompt: 'hi', - updatedAt: 1000, - stateStartedAt: 1000, - agentType: 'codex', - stateHistory: [] - } - } - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('Created https://github.com/acme/orca/pull/42\r\n') - capturedDataCallback.current?.('\x1b]133;D;130\x07prompt $ ') - - expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() - expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - }) - - it('seeds and settles Command Code status from command-code facts', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const handler = await import('./terminal-side-effect-facts-handler') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - const paneKey = makePaneKey('tab-1', LEAF_1) - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void - onPtySpawn('pty-fact-cc') - - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-cc', - seq: 1, - facts: [{ kind: 'command-code-working', prompt: 'say hi' }] - }) - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'working', - prompt: 'say hi', - agentType: 'command-code' - }) - - // Why: done is a hint — the settle timer stays in pane policy so it can consult the live status row before completing. - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-cc', - seq: 2, - facts: [{ kind: 'command-code-done', prompt: 'say hi' }] - }) - vi.advanceTimersByTime(1499) - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'working' }) - vi.advanceTimersByTime(1) - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - prompt: 'say hi', - agentType: 'command-code' - }) - }) - - it('rejects Command Code facts when Claude owns the pane', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const handler = await import('./terminal-side-effect-facts-handler') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - const paneKey = makePaneKey('tab-1', LEAF_1) - const claudeStatus = { - paneKey, - state: 'done' as const, - prompt: 'Previous Claude turn', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'claude' as const, - stateHistory: [] - } - mockStoreState.tabsByWorktree = { - 'wt-1': [{ id: 'tab-1', ptyId: null, launchAgent: 'claude' }] - } - mockStoreState.agentStatusByPaneKey[paneKey] = claudeStatus - mockStoreState.paneForegroundAgentByPaneKey[paneKey] = { - agent: 'claude', - shellForeground: false - } - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void - onPtySpawn('pty-fact-false-cc') - mockStoreState.setAgentStatus.mockClear() - - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-false-cc', - seq: 1, - facts: [ - { kind: 'command-code-working', prompt: 'False prompt' }, - { kind: 'command-code-done', prompt: 'False prompt' } - ] - }) - vi.advanceTimersByTime(2000) - - expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBe(claudeStatus) - }) - - it('rejects Command Code facts when retained Claude identity owns the pane', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const handler = await import('./terminal-side-effect-facts-handler') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - const paneKey = makePaneKey('tab-1', LEAF_1) - const unknownStatus = { - paneKey, - state: 'working' as const, - prompt: '', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'unknown' as const, - stateHistory: [] - } - mockStoreState.retainedAgentsByPaneKey[paneKey] = { agentType: 'claude' } - mockStoreState.agentStatusByPaneKey[paneKey] = unknownStatus - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void - onPtySpawn('pty-fact-retained-false-cc') - mockStoreState.setAgentStatus.mockClear() - - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-retained-false-cc', - seq: 1, - facts: [ - { kind: 'command-code-working', prompt: 'False prompt' }, - { kind: 'command-code-done', prompt: 'False prompt' } - ] - }) - vi.advanceTimersByTime(2000) - - expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBe(unknownStatus) - }) - - it('keeps Command Code working when a working fact lands before the done settles', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const handler = await import('./terminal-side-effect-facts-handler') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - vi.useFakeTimers() - const paneKey = makePaneKey('tab-1', LEAF_1) - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void - onPtySpawn('pty-fact-cc-repaint') - - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-cc-repaint', - seq: 1, - facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] - }) - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-cc-repaint', - seq: 2, - facts: [{ kind: 'command-code-done', prompt: 'Run a slow command' }] - }) - vi.advanceTimersByTime(1000) - // An active repaint within the settle window cancels the pending done. - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-fact-cc-repaint', - seq: 3, - facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] - }) - vi.advanceTimersByTime(2000) - - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'working', - prompt: 'Run a slow command', - agentType: 'command-code' - }) - }) - - it('does not byte-scan Command Code output — facts are the only consumer', async () => { - enableMainAuthority() - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-authority-cc-bytes' - } - ) - transportFactoryQueue.push(transport) - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ startup: { command: 'command-code --trust' } }) as never - ) - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('# Command Code v0.27.2\r\n') - capturedDataCallback.current?.('❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') - - expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() - }) - - it('honors the persisted kill switch for panes bound before settings hydrate', async () => { - // Pre-hydration: settings not loaded but kill switch persisted off — pane registers byte parsers, not a fact consumer. - mockStoreState.settings = null - ;(window.api as unknown as Record).settings = { - getSync: vi.fn(() => ({ terminalMainSideEffectAuthority: false })) - } - const { connectPanePty } = await import('./pty-connection') - const handler = await import('./terminal-side-effect-facts-handler') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - const deps = createDeps() - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - - for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { - expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') - } - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void - onPtySpawn('pty-prehydration') - - // No fact consumer registered: channel batches are dropped. - handler._dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-prehydration', - seq: 1, - facts: [{ kind: 'bell' }] - }) - expect(deps.markTerminalBell).not.toHaveBeenCalled() - - // Hydration with switch still off: byte parsing stays the single consumer — one BEL marks unread once. - mockStoreState.settings = { terminalMainSideEffectAuthority: false } - notifyStoreSubscribers() - const onBell = createdTransportOptions[0]?.onBell as () => void - onBell() - expect(deps.markTerminalBell).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-mocks.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-mocks.spec.ts new file mode 100644 index 00000000000..d5188e87ed0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-mocks.spec.ts @@ -0,0 +1,140 @@ +// oxlint-disable anti-slop/no-module-mocking -- Test-only mocks shared by PTY connection registration suites. +import type * as React from 'react' +import { vi } from 'vitest' +import type { Mock } from 'vitest' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import type { StoreState } from './pty-connection-test-store-state' + +type StoreSubscriber = (state: StoreState) => void + +type PtyConnectionTestBindings = { + getState: () => StoreState + getTransportQueue: () => MockTransport[] + getTransportOptions: () => Record[] + getStoreSubscribers: () => StoreSubscriber[] + setStoreSubscribers: (subscribers: StoreSubscriber[]) => void +} + +type PtyConnectionTestMocks = { + resetAndRefreshAllTerminalWebglAtlases: Mock + scheduleTerminalWebglAtlasRecovery: Mock + scheduleRuntimeGraphSync: Mock + shouldSeedCacheTimerOnInitialTitle: Mock<() => boolean> + toastInfo: Mock + notifyCodexPaneBoundForStaleSweep: Mock +} + +const fixture = vi.hoisted(() => { + let bindings: PtyConnectionTestBindings | undefined + return { + mocks: { + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() + }, + bind(next: PtyConnectionTestBindings): void { + bindings = next + }, + current(): PtyConnectionTestBindings { + if (!bindings) { + throw new Error('No PTY connection test state is bound') + } + return bindings + } + } +}) + +export const ptyConnectionTestMocks: PtyConnectionTestMocks = fixture.mocks + +export function bindPtyConnectionTestMocks(bindings: PtyConnectionTestBindings): void { + fixture.bind(bindings) +} + +function takeMockTransport(options: Record): MockTransport { + const bindings = fixture.current() + bindings.getTransportOptions().push(options) + const transport = bindings.getTransportQueue().shift() + if (!transport) { + throw new Error('No mock transport queued') + } + return transport +} + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync: fixture.mocks.scheduleRuntimeGraphSync +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases: fixture.mocks.resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery: fixture.mocks.scheduleTerminalWebglAtlasRecovery +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => fixture.current().getState(), + subscribe: (listener: StoreSubscriber) => { + // Unsubscribe must keep the suite that created the subscription. + const bindings = fixture.current() + bindings.getStoreSubscribers().push(listener) + return () => { + bindings.setStoreSubscribers( + bindings.getStoreSubscribers().filter((candidate) => candidate !== listener) + ) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle: fixture.mocks.shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ + toast: { + info: fixture.mocks.toastInfo + } +})) + +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ + notifyCodexPaneBoundForStaleSweep: fixture.mocks.notifyCodexPaneBoundForStaleSweep +})) + +// Notification hooks run outside React in these suites. +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => takeMockTransport(options)) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => takeMockTransport(options) + ) +})) + +// Only the eager-buffer read is replaced; the remaining dispatcher stays real. +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.spec.ts new file mode 100644 index 00000000000..f9f72078752 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.spec.ts @@ -0,0 +1,301 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionMode2031SubscriptionsTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + describe('mode 2031 subscriptions are tracked per raw PTY chunk, never answered', () => { + async function connectVisiblePane(): Promise<{ + transport: ReturnType + deps: ReturnType + emit: (data: string, meta?: { droppedOutput?: boolean }) => void + dispose: () => void + }> { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const captured: { + current: ((data: string, meta?: { droppedOutput?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + captured.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + settings: { ...mockStoreState.settings, theme: 'light' } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ isVisibleRef: { current: true } }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + return { + transport, + deps, + emit: (data: string, meta?: { droppedOutput?: boolean }) => + captured.current?.(data, meta), + dispose: () => binding.dispose() + } + } + + const replies = (transport: { sendInput: { mock: { calls: unknown[][] } } }): unknown[] => + transport.sendInput.mock.calls.flat().filter((arg) => String(arg).includes('997')) + + it('registers a chunk that ends subscribed, then retires it when the next withdraws', async () => { + const { transport, deps, emit, dispose } = await connectVisiblePane() + // Two separate PTY chunks. xterm would parse both in one batch and see only the net result. + emit('\x1b[?2031h') + expect(deps.paneMode2031Ref.current.get(1)).toBe(true) + emit('\x1b[?2031l') + expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() + expect(replies(transport)).toEqual([]) + dispose() + }) + + it('re-registers a chunk that withdraws and subscribes again', async () => { + const { transport, deps, emit, dispose } = await connectVisiblePane() + emit('\x1b[?2031h') + emit('\x1b[?2031l\x1b[?2031h') + expect(deps.paneMode2031Ref.current.get(1)).toBe(true) + expect(replies(transport)).toEqual([]) + dispose() + }) + + it('registers nothing when one chunk both subscribes and withdraws', async () => { + const { transport, deps, emit, dispose } = await connectVisiblePane() + // The fish prompt case: the subscription is gone before the program could read anything. + emit('\x1b[?2031h prompt \x1b[?2031l') + expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() + expect(replies(transport)).toEqual([]) + dispose() + }) + + it('registers nothing when the withdrawal is split across two chunks', async () => { + const { transport, deps, emit, dispose } = await connectVisiblePane() + emit('\x1b[?2031h prompt \x1b[?20') + emit('31l') + expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() + expect(replies(transport)).toEqual([]) + dispose() + }) + + it('registers nothing when an unrelated private mode appends a split withdrawal', async () => { + const { transport, deps, emit, dispose } = await connectVisiblePane() + emit('\x1b[?2031h prompt \x1b[?25') + emit(';2031l') + expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() + expect(replies(transport)).toEqual([]) + dispose() + }) + + it('registers the subscription once an ambiguous tail resolves to another mode', async () => { + const { transport, deps, emit, dispose } = await connectVisiblePane() + emit('\x1b[?2031h drawing \x1b[?20') + expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() + emit('25h') + expect(deps.paneMode2031Ref.current.get(1)).toBe(true) + expect(replies(transport)).toEqual([]) + dispose() + }) + + // One fish prompt cycle, exactly as fish's tty_handoff.rs emits it. + const FISH_PROMPT_HANDOFF = '\x1b[?2031h\x1b[0m~/orca \x1b[32m❯\x1b[0m \x1b[?2031l' + + it('stays silent across three fish prompts', async () => { + const { transport, emit, dispose } = await connectVisiblePane() + emit(FISH_PROMPT_HANDOFF) + emit(FISH_PROMPT_HANDOFF) + emit(FISH_PROMPT_HANDOFF) + expect(replies(transport)).toEqual([]) + dispose() + }) + + it('leaves no stale subscription behind after a fish prompt cycle', async () => { + const { deps, emit, dispose } = await connectVisiblePane() + emit(FISH_PROMPT_HANDOFF) + // A later theme flip must not push CSI 997 at a shell that unsubscribed. + expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() + dispose() + }) + + it('registers a TUI that subscribes at the end of a fish prompt chunk', async () => { + const { transport, deps, emit, dispose } = await connectVisiblePane() + emit(`${FISH_PROMPT_HANDOFF}\x1b[?2031h`) + expect(deps.paneMode2031Ref.current.get(1)).toBe(true) + expect(replies(transport)).toEqual([]) + dispose() + }) + + it('keeps a subscription whose withdrawal never arrives', async () => { + const { transport, deps, emit, dispose } = await connectVisiblePane() + // A real TUI: subscribe now, unsubscribe minutes later on exit. + emit('\x1b[?2031h') + emit('painting the ui') + expect(deps.paneMode2031Ref.current.get(1)).toBe(true) + expect(replies(transport)).toEqual([]) + dispose() + }) + + it('drops a live subscription when a later chunk withdraws it', async () => { + const { deps, emit, dispose } = await connectVisiblePane() + // A TUI that exits: the earlier chunk really did register, so the withdrawal has + // state to undo — otherwise a theme flip pushes CSI 997 at the shell that replaced it. + emit('\x1b[?2031h') + expect(deps.paneMode2031Ref.current.get(1)).toBe(true) + emit('\x1b[?2031l') + expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() + expect(deps.paneLastThemeModeRef.current.get(1)).toBeUndefined() + dispose() + }) + + it('discards a half-read escape prefix when the pane swaps PTYs', async () => { + const { transport, deps, emit, dispose } = await connectVisiblePane() + // Why: the tail is a byte range from the old stream. Splicing it onto the first + // chunk of a replacement PTY fabricates a subscribe no program ever sent. + transport.serializeBuffer = vi.fn().mockResolvedValue(null) + emit('\x1b[?20') + // droppedOutput latches the hidden-restore PTY id, which is what detects the swap. + emit('', { droppedOutput: true }) + transport.getPtyId.mockReturnValue('pty-id-2') + + emit('31h') + + expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() + expect(replies(transport)).toEqual([]) + dispose() + }) + }) + + it('records hidden Codex mode 2031 subscribes split across becoming visible', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + settings: { ...mockStoreState.settings, theme: 'light' } + } + + const isVisibleRef = { current: false } + const paneMode2031Ref = { current: new Map() } + const paneLastThemeModeRef = { current: new Map() } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + paneMode2031Ref, + paneLastThemeModeRef, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?20') + isVisibleRef.current = true + capturedDataCallback.current?.('31h') + + expect(transport.sendInputImmediate).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) + expect(paneMode2031Ref.current.get(1)).toBe(true) + // Seeded so the next appearance re-apply only pushes on a real color-mode flip. + expect(paneLastThemeModeRef.current.get(1)).toBe('light') + expect(pane.terminal.write).not.toHaveBeenCalledWith('31h', expect.any(Function)) + + binding.dispose() + }) + + it('does not keep mode 2031 subscribed when a skipped hidden chunk unsubscribes last', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const paneMode2031Ref = { current: new Map() } + const paneLastThemeModeRef = { current: new Map() } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + paneMode2031Ref, + paneLastThemeModeRef, + startup: { command: 'codex' } + }) as never + ) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?2031h\x1b[?2031l') + + expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) + expect(paneMode2031Ref.current.has(1)).toBe(false) + expect(paneLastThemeModeRef.current.has(1)).toBe(false) + + binding.dispose() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.test.ts deleted file mode 100644 index 29b82144c97..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - describe('mode 2031 subscriptions are tracked per raw PTY chunk, never answered', () => { - async function connectVisiblePane(): Promise<{ - transport: ReturnType - deps: ReturnType - emit: (data: string, meta?: { droppedOutput?: boolean }) => void - dispose: () => void - }> { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const captured: { - current: ((data: string, meta?: { droppedOutput?: boolean }) => void) | null - } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - captured.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - settings: { ...mockStoreState.settings, theme: 'light' } - } - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ isVisibleRef: { current: true } }) - const binding = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - return { - transport, - deps, - emit: (data: string, meta?: { droppedOutput?: boolean }) => captured.current?.(data, meta), - dispose: () => binding.dispose() - } - } - - const replies = (transport: { sendInput: { mock: { calls: unknown[][] } } }): unknown[] => - transport.sendInput.mock.calls.flat().filter((arg) => String(arg).includes('997')) - - it('registers a chunk that ends subscribed, then retires it when the next withdraws', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - // Two separate PTY chunks. xterm would parse both in one batch and see only the net result. - emit('\x1b[?2031h') - expect(deps.paneMode2031Ref.current.get(1)).toBe(true) - emit('\x1b[?2031l') - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('re-registers a chunk that withdraws and subscribes again', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - emit('\x1b[?2031h') - emit('\x1b[?2031l\x1b[?2031h') - expect(deps.paneMode2031Ref.current.get(1)).toBe(true) - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('registers nothing when one chunk both subscribes and withdraws', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - // The fish prompt case: the subscription is gone before the program could read anything. - emit('\x1b[?2031h prompt \x1b[?2031l') - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('registers nothing when the withdrawal is split across two chunks', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - emit('\x1b[?2031h prompt \x1b[?20') - emit('31l') - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('registers nothing when an unrelated private mode appends a split withdrawal', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - emit('\x1b[?2031h prompt \x1b[?25') - emit(';2031l') - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('registers the subscription once an ambiguous tail resolves to another mode', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - emit('\x1b[?2031h drawing \x1b[?20') - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - emit('25h') - expect(deps.paneMode2031Ref.current.get(1)).toBe(true) - expect(replies(transport)).toEqual([]) - dispose() - }) - - // One fish prompt cycle, exactly as fish's tty_handoff.rs emits it. - const FISH_PROMPT_HANDOFF = '\x1b[?2031h\x1b[0m~/orca \x1b[32m❯\x1b[0m \x1b[?2031l' - - it('stays silent across three fish prompts', async () => { - const { transport, emit, dispose } = await connectVisiblePane() - emit(FISH_PROMPT_HANDOFF) - emit(FISH_PROMPT_HANDOFF) - emit(FISH_PROMPT_HANDOFF) - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('leaves no stale subscription behind after a fish prompt cycle', async () => { - const { deps, emit, dispose } = await connectVisiblePane() - emit(FISH_PROMPT_HANDOFF) - // A later theme flip must not push CSI 997 at a shell that unsubscribed. - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - dispose() - }) - - it('registers a TUI that subscribes at the end of a fish prompt chunk', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - emit(`${FISH_PROMPT_HANDOFF}\x1b[?2031h`) - expect(deps.paneMode2031Ref.current.get(1)).toBe(true) - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('keeps a subscription whose withdrawal never arrives', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - // A real TUI: subscribe now, unsubscribe minutes later on exit. - emit('\x1b[?2031h') - emit('painting the ui') - expect(deps.paneMode2031Ref.current.get(1)).toBe(true) - expect(replies(transport)).toEqual([]) - dispose() - }) - - it('drops a live subscription when a later chunk withdraws it', async () => { - const { deps, emit, dispose } = await connectVisiblePane() - // A TUI that exits: the earlier chunk really did register, so the withdrawal has - // state to undo — otherwise a theme flip pushes CSI 997 at the shell that replaced it. - emit('\x1b[?2031h') - expect(deps.paneMode2031Ref.current.get(1)).toBe(true) - emit('\x1b[?2031l') - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - expect(deps.paneLastThemeModeRef.current.get(1)).toBeUndefined() - dispose() - }) - - it('discards a half-read escape prefix when the pane swaps PTYs', async () => { - const { transport, deps, emit, dispose } = await connectVisiblePane() - // Why: the tail is a byte range from the old stream. Splicing it onto the first - // chunk of a replacement PTY fabricates a subscribe no program ever sent. - transport.serializeBuffer = vi.fn().mockResolvedValue(null) - emit('\x1b[?20') - // droppedOutput latches the hidden-restore PTY id, which is what detects the swap. - emit('', { droppedOutput: true }) - transport.getPtyId.mockReturnValue('pty-id-2') - - emit('31h') - - expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined() - expect(replies(transport)).toEqual([]) - dispose() - }) - }) - - it('records hidden Codex mode 2031 subscribes split across becoming visible', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - settings: { ...mockStoreState.settings, theme: 'light' } - } - - const isVisibleRef = { current: false } - const paneMode2031Ref = { current: new Map() } - const paneLastThemeModeRef = { current: new Map() } - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef, - paneMode2031Ref, - paneLastThemeModeRef, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[?20') - isVisibleRef.current = true - capturedDataCallback.current?.('31h') - - expect(transport.sendInputImmediate).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - expect(paneMode2031Ref.current.get(1)).toBe(true) - // Seeded so the next appearance re-apply only pushes on a real color-mode flip. - expect(paneLastThemeModeRef.current.get(1)).toBe('light') - expect(pane.terminal.write).not.toHaveBeenCalledWith('31h', expect.any(Function)) - - binding.dispose() - }) - - it('does not keep mode 2031 subscribed when a skipped hidden chunk unsubscribes last', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const paneMode2031Ref = { current: new Map() } - const paneLastThemeModeRef = { current: new Map() } - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - paneMode2031Ref, - paneLastThemeModeRef, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[?2031h\x1b[?2031l') - - expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/)) - expect(paneMode2031Ref.current.has(1)).toBe(false) - expect(paneLastThemeModeRef.current.has(1)).toBe(false) - - binding.dispose() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.spec.ts new file mode 100644 index 00000000000..8d84b7b467b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.spec.ts @@ -0,0 +1,591 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './pty-connection-test-constants' +import { + LEAF_1, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import type * as UseNotificationDispatchModule from './use-notification-dispatch' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionNotificationSettingsGatingTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + function notifyStoreSubscribers(): void { + for (const listener of storeSubscribers.slice()) { + listener(mockStoreState) + } + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('lets concurrent agent-complete notifications win over terminal bell notifications', async () => { + const { connectPanePty } = await import('./pty-connection') + const { useNotificationDispatch } = await vi.importActual< + typeof UseNotificationDispatchModule + >('./use-notification-dispatch') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const dispatchNotification = useNotificationDispatch('wt-1') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'Fix notification payloads', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'codex', + paneKey, + terminalTitle: '* Codex done', + stateHistory: [], + lastAssistantMessage: 'Implemented the formatter.' + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ dispatchNotification }) + + connectPanePty(pane as never, manager as never, deps as never) + + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!bellHandler || !idleHandler) { + throw new Error('Expected bell and idle handlers to be registered') + } + + bellHandler() + idleHandler('* Codex done') + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + await flushAsyncTicks() + + expect(window.api.notifications.dispatch).toHaveBeenCalledTimes(1) + expect(window.api.notifications.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent-task-complete', + terminalTitle: '* Codex done', + agentType: 'codex', + agentLastAssistantMessage: 'Implemented the formatter.' + }) + ) + }) + + it('does not suppress terminal bell notifications when agent-complete notifications are disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!bellHandler || !idleHandler) { + throw new Error('Expected bell and idle handlers to be registered') + } + + bellHandler() + idleHandler('* Codex done') + vi.advanceTimersByTime(250) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent-task-complete' + }) + ) + }) + + it('raises terminal attention for agent completion when OS completion notifications are disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: true, + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!idleHandler) { + throw new Error('Expected idle handler to be registered') + } + + idleHandler('* Codex done') + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent-task-complete', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + ) + }) + + it('shares one raw store subscriber for agent-complete notification settings across panes', async () => { + const { connectPanePty } = await import('./pty-connection') + transportFactoryQueue.push(createMockTransport('pty-1'), createMockTransport('pty-2')) + + vi.useFakeTimers() + const firstBinding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps() as never + ) + const secondBinding = connectPanePty( + createPane(2) as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks() + + expect(storeSubscribers).toHaveLength(1) + + firstBinding.dispose() + expect(storeSubscribers).toHaveLength(1) + secondBinding.dispose() + expect(storeSubscribers).toHaveLength(0) + }) + + it('does not dispatch generic title completions when agent-complete notifications are disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const api = ( + globalThis as unknown as { + window: { api: { pty: { getForegroundProcess: ReturnType } } } + } + ).window.api + api.pty.getForegroundProcess.mockResolvedValue('codex') + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') + titleHandler('experimental-agent-observability', 'experimental-agent-observability') + await flushAsyncTicks() + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('does not replay disabled generic title completions after notifications are re-enabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const inspection = createDeferred() + const api = ( + globalThis as unknown as { + window: { api: { pty: { getForegroundProcess: ReturnType } } } + } + ).window.api + api.pty.getForegroundProcess.mockReturnValue(inspection.promise) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') + titleHandler('experimental-agent-observability', 'experimental-agent-observability') + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + inspection.resolve('codex') + await flushAsyncTicks() + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('clears title completion state when notifications are disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('Claude working', 'Claude working') + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: false + } + } + notifyStoreSubscribers() + titleHandler('Claude done', 'Claude done') + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + notifyStoreSubscribers() + titleHandler('Claude done', 'Claude done') + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('cancels scheduled agent completion when notifications are disabled before dispatch', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!idleHandler) { + throw new Error('Expected onAgentBecameIdle to be registered') + } + + idleHandler('* Codex done') + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: false + } + } + notifyStoreSubscribers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + notifyStoreSubscribers() + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('restores a suppressed terminal bell when disabling pending agent completion', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!bellHandler || !idleHandler) { + throw new Error('Expected bell and idle handlers to be registered') + } + + bellHandler() + idleHandler('* Codex done') + vi.advanceTimersByTime(250) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: false + } + } + notifyStoreSubscribers() + vi.advanceTimersByTime(250) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + }) + + it('requires fresh working evidence after notifications are disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as + | (() => void) + | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!workingHandler || !idleHandler) { + throw new Error('Expected working and idle handlers to be registered') + } + + workingHandler() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: false + } + } + notifyStoreSubscribers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + notifyStoreSubscribers() + idleHandler('* Codex done') + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('requires fresh working evidence when notifications start disabled then re-enable', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as + | (() => void) + | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!workingHandler || !idleHandler) { + throw new Error('Expected working and idle handlers to be registered') + } + + workingHandler() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + notifyStoreSubscribers() + idleHandler('* Codex done') + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.test.ts deleted file mode 100644 index 7b5dfd3c1ce..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.test.ts +++ /dev/null @@ -1,682 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' -import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './pty-connection-test-constants' -import { - LEAF_1, - createMockTransport, - createPane, - createManager -} from './pty-connection-test-pane-fixtures' -import type * as UseNotificationDispatchModule from './use-notification-dispatch' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import type { MockTransport } from './pty-connection-test-pane-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -function notifyStoreSubscribers(): void { - for (const listener of storeSubscribers.slice()) { - listener(mockStoreState) - } -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('lets concurrent agent-complete notifications win over terminal bell notifications', async () => { - const { connectPanePty } = await import('./pty-connection') - const { useNotificationDispatch } = await vi.importActual( - './use-notification-dispatch' - ) - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - const dispatchNotification = useNotificationDispatch('wt-1') - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'Fix notification payloads', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'codex', - paneKey, - terminalTitle: '* Codex done', - stateHistory: [], - lastAssistantMessage: 'Implemented the formatter.' - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ dispatchNotification }) - - connectPanePty(pane as never, manager as never, deps as never) - - const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!bellHandler || !idleHandler) { - throw new Error('Expected bell and idle handlers to be registered') - } - - bellHandler() - idleHandler('* Codex done') - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - await flushAsyncTicks() - - expect(window.api.notifications.dispatch).toHaveBeenCalledTimes(1) - expect(window.api.notifications.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'agent-task-complete', - terminalTitle: '* Codex done', - agentType: 'codex', - agentLastAssistantMessage: 'Implemented the formatter.' - }) - ) - }) - - it('does not suppress terminal bell notifications when agent-complete notifications are disabled', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - enabled: true, - agentTaskComplete: false, - terminalBell: true, - suppressWhenFocused: false, - customSoundPath: null - } - } - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!bellHandler || !idleHandler) { - throw new Error('Expected bell and idle handlers to be registered') - } - - bellHandler() - idleHandler('* Codex done') - vi.advanceTimersByTime(250) - - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ source: 'terminal-bell' }) - ) - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'agent-task-complete' - }) - ) - }) - - it('raises terminal attention for agent completion when OS completion notifications are disabled', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-codex') - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - mockStoreState.settings = { - ...mockStoreState.settings, - experimentalTerminalAttention: true, - notifications: { - enabled: true, - agentTaskComplete: false, - terminalBell: true, - suppressWhenFocused: false, - customSoundPath: null - } - } - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!idleHandler) { - throw new Error('Expected idle handler to be registered') - } - - idleHandler('* Codex done') - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'agent-task-complete', - paneKey: makePaneKey('tab-1', LEAF_1) - }) - ) - }) - - it('shares one raw store subscriber for agent-complete notification settings across panes', async () => { - const { connectPanePty } = await import('./pty-connection') - transportFactoryQueue.push(createMockTransport('pty-1'), createMockTransport('pty-2')) - - vi.useFakeTimers() - const firstBinding = connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps() as never - ) - const secondBinding = connectPanePty( - createPane(2) as never, - createManager(1) as never, - createDeps() as never - ) - await flushAsyncTicks() - - expect(storeSubscribers).toHaveLength(1) - - firstBinding.dispose() - expect(storeSubscribers).toHaveLength(1) - secondBinding.dispose() - expect(storeSubscribers).toHaveLength(0) - }) - - it('does not dispatch generic title completions when agent-complete notifications are disabled', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-codex') - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - enabled: true, - agentTaskComplete: false, - terminalBell: true, - suppressWhenFocused: false, - customSoundPath: null - } - } - const api = ( - globalThis as unknown as { - window: { api: { pty: { getForegroundProcess: ReturnType } } } - } - ).window.api - api.pty.getForegroundProcess.mockResolvedValue('codex') - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - - titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') - titleHandler('experimental-agent-observability', 'experimental-agent-observability') - await flushAsyncTicks() - vi.advanceTimersByTime(1_000) - - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - }) - - it('does not replay disabled generic title completions after notifications are re-enabled', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-codex') - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - enabled: true, - agentTaskComplete: false, - terminalBell: true, - suppressWhenFocused: false, - customSoundPath: null - } - } - const inspection = createDeferred() - const api = ( - globalThis as unknown as { - window: { api: { pty: { getForegroundProcess: ReturnType } } } - } - ).window.api - api.pty.getForegroundProcess.mockReturnValue(inspection.promise) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - - titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') - titleHandler('experimental-agent-observability', 'experimental-agent-observability') - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - ...mockStoreState.settings.notifications, - agentTaskComplete: true - } - } - inspection.resolve('codex') - await flushAsyncTicks() - vi.advanceTimersByTime(1_000) - - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - }) - - it('clears title completion state when notifications are disabled', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-codex') - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - enabled: true, - agentTaskComplete: true, - terminalBell: true, - suppressWhenFocused: false, - customSoundPath: null - } - } - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - - titleHandler('Claude working', 'Claude working') - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - ...mockStoreState.settings.notifications, - agentTaskComplete: false - } - } - notifyStoreSubscribers() - titleHandler('Claude done', 'Claude done') - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - ...mockStoreState.settings.notifications, - agentTaskComplete: true - } - } - notifyStoreSubscribers() - titleHandler('Claude done', 'Claude done') - vi.advanceTimersByTime(1_000) - - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - }) - - it('cancels scheduled agent completion when notifications are disabled before dispatch', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-codex') - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - enabled: true, - agentTaskComplete: true, - terminalBell: true, - suppressWhenFocused: false, - customSoundPath: null - } - } - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!idleHandler) { - throw new Error('Expected onAgentBecameIdle to be registered') - } - - idleHandler('* Codex done') - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - ...mockStoreState.settings.notifications, - agentTaskComplete: false - } - } - notifyStoreSubscribers() - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - ...mockStoreState.settings.notifications, - agentTaskComplete: true - } - } - notifyStoreSubscribers() - vi.advanceTimersByTime(1_000) - - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - }) - - it('restores a suppressed terminal bell when disabling pending agent completion', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-codex') - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - enabled: true, - agentTaskComplete: true, - terminalBell: true, - suppressWhenFocused: false, - customSoundPath: null - } - } - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!bellHandler || !idleHandler) { - throw new Error('Expected bell and idle handlers to be registered') - } - - bellHandler() - idleHandler('* Codex done') - vi.advanceTimersByTime(250) - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'terminal-bell' }) - ) - - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - ...mockStoreState.settings.notifications, - agentTaskComplete: false - } - } - notifyStoreSubscribers() - vi.advanceTimersByTime(250) - - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ source: 'terminal-bell' }) - ) - }) - - it('requires fresh working evidence after notifications are disabled', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-codex') - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - enabled: true, - agentTaskComplete: true, - terminalBell: true, - suppressWhenFocused: false, - customSoundPath: null - } - } - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as - | (() => void) - | undefined - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!workingHandler || !idleHandler) { - throw new Error('Expected working and idle handlers to be registered') - } - - workingHandler() - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - ...mockStoreState.settings.notifications, - agentTaskComplete: false - } - } - notifyStoreSubscribers() - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - ...mockStoreState.settings.notifications, - agentTaskComplete: true - } - } - notifyStoreSubscribers() - idleHandler('* Codex done') - vi.advanceTimersByTime(1_000) - - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - }) - - it('requires fresh working evidence when notifications start disabled then re-enable', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-codex') - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - enabled: true, - agentTaskComplete: false, - terminalBell: true, - suppressWhenFocused: false, - customSoundPath: null - } - } - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as - | (() => void) - | undefined - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!workingHandler || !idleHandler) { - throw new Error('Expected working and idle handlers to be registered') - } - - workingHandler() - mockStoreState.settings = { - ...mockStoreState.settings, - notifications: { - ...mockStoreState.settings.notifications, - agentTaskComplete: true - } - } - notifyStoreSubscribers() - idleHandler('* Codex done') - vi.advanceTimersByTime(1_000) - - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.spec.ts new file mode 100644 index 00000000000..f1d022054c1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.spec.ts @@ -0,0 +1,168 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { createRect } from './pty-connection-test-dom' +import { + LEAF_1, + createMockTransport, + createPane, + captureCallbackTerminalWrites, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionParkRevealModelSnapshotTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('restores configured paired scrollback after an ordinary park reveal', async () => { + const { connectPanePty } = await import('./pty-connection') + const remotePtyId = 'remote:env-1@@terminal-1' + const transport = createMockTransport(remotePtyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + transport.getPtyId.mockReturnValue(remotePtyId) + callbacks.onReplayData?.('current screen from initial subscribe\r\n') + return { id: remotePtyId, isReattach: true, replay: '' } + } + ) + transport.serializeBuffer = vi.fn().mockResolvedValue({ + data: 'DEEP_PAIRED_SCROLLBACK\r\ncurrent screen\r\n', + cols: 100, + rows: 30, + seq: 4_096, + source: 'headless' + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + runtimeStatusByEnvironmentId: new Map([ + [ + 'env-1', + { + checkedAt: Date.now(), + status: { capabilities: ['terminal.paired-parking.v1'] } + } + ] + ]) + } + + const pane = createPane(1) + const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ mountFollowsTerminalPark: true }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + for (let step = 0; step < 30; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + + expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(transport.attach).not.toHaveBeenCalled() + expect(writes.join('')).toContain('DEEP_PAIRED_SCROLLBACK') + expect(writes.join('')).toContain('current screen from initial subscribe') + expect(transport.getPtyId).toHaveReturnedWith(remotePtyId) + }) + + it('keeps a parked main-model alt frame at its capture grid while hidden', async () => { + const { connectPanePty } = await import('./pty-connection') + const localPtyId = 'global-floating-terminal@@terminal-1' + const transport = createMockTransport(localPtyId) + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + transport.getPtyId.mockReturnValue(localPtyId) + return sessionId ? { id: localPtyId, isReattach: true } : null + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + getMainBufferSnapshot.mockResolvedValue({ + data: 'FLOATING-PARK-ALT-FRAME', + frameRestoreAnsi: '\x1b[?25l', + cols: 113, + rows: 32, + seq: 558, + source: 'headless', + alternateScreen: true, + scrollbackAnsi: 'FLOATING-PARK-HISTORY\r\n' + }) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'global-floating-terminal': [{ id: 'tab-1', ptyId: localPtyId }] + }, + ptyIdsByTabId: { 'tab-1': [localPtyId] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: localPtyId } + } + } + } + + const pane = createPane(1) + pane.container.getBoundingClientRect = vi.fn(() => createRect(0, 0)) + const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + mountFollowsTerminalPark: true, + worktreeId: 'global-floating-terminal', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: localPtyId } + }) + connectPanePty(pane as never, createManager(1) as never, deps as never) + for (let step = 0; step < 30; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + + expect(getMainBufferSnapshot).toHaveBeenCalledOnce() + expect(getMainBufferSnapshot).toHaveBeenCalledWith(localPtyId, { scrollbackRows: 5000 }) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: localPtyId }) + ) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, localPtyId) + expect(writes.join('')).toContain('FLOATING-PARK-HISTORY') + expect(writes.join('')).toContain('FLOATING-PARK-ALT-FRAME') + expect(writes.filter((write) => write.includes('FLOATING-PARK-ALT-FRAME'))).toHaveLength(1) + expect(pane.terminal.resize).toHaveBeenCalledWith(113, 32) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.test.ts deleted file mode 100644 index 7d72fe3677f..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { createRect } from './pty-connection-test-dom' -import { - LEAF_1, - createMockTransport, - createPane, - captureCallbackTerminalWrites, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('restores configured paired scrollback after an ordinary park reveal', async () => { - const { connectPanePty } = await import('./pty-connection') - const remotePtyId = 'remote:env-1@@terminal-1' - const transport = createMockTransport(remotePtyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - transport.getPtyId.mockReturnValue(remotePtyId) - callbacks.onReplayData?.('current screen from initial subscribe\r\n') - return { id: remotePtyId, isReattach: true, replay: '' } - }) - transport.serializeBuffer = vi.fn().mockResolvedValue({ - data: 'DEEP_PAIRED_SCROLLBACK\r\ncurrent screen\r\n', - cols: 100, - rows: 30, - seq: 4_096, - source: 'headless' - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - runtimeStatusByEnvironmentId: new Map([ - [ - 'env-1', - { - checkedAt: Date.now(), - status: { capabilities: ['terminal.paired-parking.v1'] } - } - ] - ]) - } - - const pane = createPane(1) - const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ mountFollowsTerminalPark: true }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - for (let step = 0; step < 30; step += 1) { - parseCallbacks.shift()?.() - await flushAsyncTicks(2) - } - - expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) - expect(transport.attach).not.toHaveBeenCalled() - expect(writes.join('')).toContain('DEEP_PAIRED_SCROLLBACK') - expect(writes.join('')).toContain('current screen from initial subscribe') - expect(transport.getPtyId).toHaveReturnedWith(remotePtyId) - }) - - it('keeps a parked main-model alt frame at its capture grid while hidden', async () => { - const { connectPanePty } = await import('./pty-connection') - const localPtyId = 'global-floating-terminal@@terminal-1' - const transport = createMockTransport(localPtyId) - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - transport.getPtyId.mockReturnValue(localPtyId) - return sessionId ? { id: localPtyId, isReattach: true } : null - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) - getMainBufferSnapshot.mockResolvedValue({ - data: 'FLOATING-PARK-ALT-FRAME', - frameRestoreAnsi: '\x1b[?25l', - cols: 113, - rows: 32, - seq: 558, - source: 'headless', - alternateScreen: true, - scrollbackAnsi: 'FLOATING-PARK-HISTORY\r\n' - }) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'global-floating-terminal': [{ id: 'tab-1', ptyId: localPtyId }] - }, - ptyIdsByTabId: { 'tab-1': [localPtyId] }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_1 }, - activeLeafId: LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_1]: localPtyId } - } - } - } - - const pane = createPane(1) - pane.container.getBoundingClientRect = vi.fn(() => createRect(0, 0)) - const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ - mountFollowsTerminalPark: true, - worktreeId: 'global-floating-terminal', - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: localPtyId } - }) - connectPanePty(pane as never, createManager(1) as never, deps as never) - for (let step = 0; step < 30; step += 1) { - parseCallbacks.shift()?.() - await flushAsyncTicks(2) - } - - expect(getMainBufferSnapshot).toHaveBeenCalledOnce() - expect(getMainBufferSnapshot).toHaveBeenCalledWith(localPtyId, { scrollbackRows: 5000 }) - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ sessionId: localPtyId }) - ) - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, localPtyId) - expect(writes.join('')).toContain('FLOATING-PARK-HISTORY') - expect(writes.join('')).toContain('FLOATING-PARK-ALT-FRAME') - expect(writes.filter((write) => write.includes('FLOATING-PARK-ALT-FRAME'))).toHaveLength(1) - expect(pane.terminal.resize).toHaveBeenCalledWith(113, 32) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.spec.ts new file mode 100644 index 00000000000..bf8b9a8fe09 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.spec.ts @@ -0,0 +1,152 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionPendingSpawnTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + const { pendingSpawnByPaneKey, pendingSpawnGenerationByPaneKey } = + await import('./pty-connection/pty-connect-limits') + pendingSpawnByPaneKey.clear() + pendingSpawnGenerationByPaneKey.clear() + await restoreTerminalTestGlobals() + }) + + it('does not retain PTY connect diagnostics unless e2e debug state is enabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + + expect((globalThis as Record).__ptyConnectDiag).toBeUndefined() + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('[pty-connect]')) + logSpy.mockRestore() + }, 30_000) + + it('does not hold a successor behind a canceled spawn serializer declaration', async () => { + const { connectPanePty } = await import('./pty-connection') + const { pendingSpawnByPaneKey } = await import('./pty-connection/pty-connect-limits') + const declaration = createDeferred() + const spawn = createDeferred() + const transport = createMockTransport() + transport.connect.mockReturnValueOnce(spawn.promise) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } + vi.mocked(window.api.pty.declarePendingPaneSerializer).mockReturnValueOnce( + declaration.promise + ) + + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks(12) + expect(pendingSpawnByPaneKey.size).toBe(1) + binding.dispose() + spawn.resolve(null) + await flushAsyncTicks(12) + + expect(pendingSpawnByPaneKey.size).toBe(0) + expect(window.api.pty.clearPendingPaneSerializer).not.toHaveBeenCalled() + + declaration.resolve(7) + await flushAsyncTicks(12) + + expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledExactlyOnceWith( + expect.any(String), + 7 + ) + }) + + it('joins a successful pending spawn across a StrictMode remount', async () => { + const { connectPanePty } = await import('./pty-connection') + const pendingSpawn = createDeferred() + const firstTransport = createMockTransport() + firstTransport.connect.mockReturnValueOnce(pendingSpawn.promise) + const remountTransport = createMockTransport() + transportFactoryQueue.push(firstTransport, remountTransport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] } + } + const remountDeps = createDeps() + + const firstBinding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks() + firstBinding.dispose() + connectPanePty(createPane(1) as never, createManager(1) as never, remountDeps as never) + await flushAsyncTicks() + + expect(firstTransport.connect).toHaveBeenCalledOnce() + expect(remountTransport.connect).not.toHaveBeenCalled() + + const spawnedPtyId = 'pty-strict-mode' + pendingSpawn.resolve(spawnedPtyId) + await flushAsyncTicks(12) + + expect(remountTransport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: spawnedPtyId }) + ) + expect(remountDeps.updateTabPtyId).toHaveBeenCalledWith('tab-1', spawnedPtyId) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts deleted file mode 100644 index 69e62842b79..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' -import { - createMockTransport, - createPane, - createManager, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - pendingSpawnByPaneKey, - pendingSpawnGenerationByPaneKey -} from './pty-connection/pty-connect-limits' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - pendingSpawnByPaneKey.clear() - pendingSpawnGenerationByPaneKey.clear() - await restoreTerminalTestGlobals() - }) - - it('does not retain PTY connect diagnostics unless e2e debug state is enabled', async () => { - const { connectPanePty } = await import('./pty-connection') - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - ptyIdsByTabId: { 'tab-1': [] } - } - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - - expect((globalThis as Record).__ptyConnectDiag).toBeUndefined() - expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('[pty-connect]')) - logSpy.mockRestore() - }, 30_000) - - it('does not hold a successor behind a canceled spawn serializer declaration', async () => { - const { connectPanePty } = await import('./pty-connection') - const declaration = createDeferred() - const spawn = createDeferred() - const transport = createMockTransport() - transport.connect.mockReturnValueOnce(spawn.promise) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - ptyIdsByTabId: { 'tab-1': [] } - } - vi.mocked(window.api.pty.declarePendingPaneSerializer).mockReturnValueOnce(declaration.promise) - - const binding = connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps() as never - ) - await flushAsyncTicks(12) - binding.dispose() - spawn.resolve(null) - await flushAsyncTicks(12) - - expect(pendingSpawnByPaneKey.size).toBe(0) - expect(window.api.pty.clearPendingPaneSerializer).not.toHaveBeenCalled() - - declaration.resolve(7) - await flushAsyncTicks(12) - - expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledExactlyOnceWith( - expect.any(String), - 7 - ) - }) - - it('joins a successful pending spawn across a StrictMode remount', async () => { - const { connectPanePty } = await import('./pty-connection') - const pendingSpawn = createDeferred() - const firstTransport = createMockTransport() - firstTransport.connect.mockReturnValueOnce(pendingSpawn.promise) - const remountTransport = createMockTransport() - transportFactoryQueue.push(firstTransport, remountTransport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, - ptyIdsByTabId: { 'tab-1': [] } - } - const remountDeps = createDeps() - - const firstBinding = connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps() as never - ) - await flushAsyncTicks() - firstBinding.dispose() - connectPanePty(createPane(1) as never, createManager(1) as never, remountDeps as never) - await flushAsyncTicks() - - expect(firstTransport.connect).toHaveBeenCalledOnce() - expect(remountTransport.connect).not.toHaveBeenCalled() - - const spawnedPtyId = 'pty-strict-mode' - pendingSpawn.resolve(spawnedPtyId) - await flushAsyncTicks(12) - - expect(remountTransport.attach).toHaveBeenCalledWith( - expect.objectContaining({ existingPtyId: spawnedPtyId }) - ) - expect(remountDeps.updateTabPtyId).toHaveBeenCalledWith('tab-1', spawnedPtyId) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts new file mode 100644 index 00000000000..9d67ce14d49 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts @@ -0,0 +1,816 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { sendTerminalInputThroughPane } from './pty-connection-test-dom' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + LEAF_2, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionPtyExitTeardownTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + function installSleepingCodexResumeState(restoredPtyId?: string) { + const paneKey = makePaneKey('tab-1', LEAF_1) + const launchConfig = { + agentCommand: "codex '--model' 'gpt-5'", + agentArgs: '--model gpt-5', + agentEnv: { CODEX_PROFILE: 'captured' } + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ...(restoredPtyId ? { ptyId: restoredPtyId } : {}) }] + }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + launchConfig + } + } + } as StoreState + return launchConfig + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('keeps the surviving split pane mounted when an intentional pane-close PTY exit arrives', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true) + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string, exitCode?: number) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + + onPtyExit?.('pty-pane-2') + + expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2') + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(2, null) + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-pane-2') + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('defers all exit-side state mutation while worktree shutdown verification is pending', async () => { + const { connectPanePty } = await import('./pty-connection') + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + let pending = true + const deps = createDeps({ + isPtyShutdownPending: vi.fn(() => pending), + consumeSuppressedPtyExit: vi.fn(() => true) + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + + onPtyExit?.('pty-pane-2') + + expect(deps.consumeSuppressedPtyExit).not.toHaveBeenCalled() + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.clearRuntimePaneTitle).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(manager.closePane).not.toHaveBeenCalled() + + pending = false + settleDeferredPtyShutdownExits(['pty-pane-2'], 'committed') + expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2') + expect(deps.clearRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 2) + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + }) + + it('keeps renderer state intact for an exit deferred through rollback', async () => { + const { connectPanePty } = await import('./pty-connection') + const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + let pending = true + const deps = createDeps({ + isPtyShutdownPending: vi.fn(() => pending), + consumeSuppressedPtyExit: vi.fn(() => true) + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + onPtyExit?.('pty-pane-2') + + pending = false + settleDeferredPtyShutdownExits(['pty-pane-2'], 'rolled-back') + + expect(deps.consumeSuppressedPtyExit).not.toHaveBeenCalled() + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.clearRuntimePaneTitle).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('preserves wake identifiers when exit arrives after a committed shutdown', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markCommittedPtyShutdowns } = await import('./pty-shutdown-exit-deferral') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + isPtyShutdownPending: vi.fn(() => false), + consumeSuppressedPtyExit: vi.fn(() => true) + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + + markCommittedPtyShutdowns(['pty-pane-2']) + onPtyExit?.('pty-pane-2') + + expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2') + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('keeps a fresh split pane mounted when its newborn PTY exits before output or input', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2, 2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }, + clearExitedPanePtyLayoutBinding: vi.fn(() => { + mockStoreState = { + ...mockStoreState, + terminalLayoutsByTabId: { + ...mockStoreState.terminalLayoutsByTabId, + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 }, + ratio: 0.5 + }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-pane-1' } + } + } + } + }) + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + + onPtyExit?.('pty-pane-2') + + expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-pane-2') + expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-pane-2') + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(manager.closePane).not.toHaveBeenCalled() + expect(manager.setActivePane).toHaveBeenCalledWith(1, { focus: true }) + }) + + it('closes a hidden split pane whose PTY exits before output instead of keeping a ghost', async () => { + // Why (regression, ghost blank pane): a hidden pane's bytes are withheld by the hidden-delivery gate, so "no output" proves nothing — keeping it strands a blank ghost on reveal. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2, 2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + isVisibleRef: { current: false }, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + + onPtyExit?.('pty-pane-2') + + expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-pane-2') + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + + it('keeps a worktree sole terminal mounted when its freshly-spawned PTY exits before input (direnv failure)', async () => { + // Why (regression): a failing .envrc direnv makes the sole terminal's shell exit immediately; routing to onPtyExitRef would close the tab and bounce the user to Landing, so keep it mounted. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(createPane(1) as never, manager as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect(onPtySpawn).toBeTypeOf('function') + expect(onPtyExit).toBeTypeOf('function') + + // A genuine fresh spawn (onPtySpawn fires only for non-reattach spawns) the user never typed into. + onPtySpawn?.('tab-pty') + onPtyExit?.('tab-pty') + + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('keeps a worktree sole terminal mounted when only a captured shortcut preceded the exit', async () => { + // Why (regression): captured shortcuts refresh the redraw window, but that must not + // count as "the user typed into this pane" — otherwise Shift+Enter before a direnv + // failure closes the tab and bounces the user to Landing. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + + const binding = connectPanePty( + createPane(1) as never, + manager as never, + deps as never + ) as unknown as { markShortcutTerminalInputSent: () => void } + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + + onPtySpawn?.('tab-pty') + binding.markShortcutTerminalInputSent() + onPtyExit?.('tab-pty') + + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('forwards a synthetic host-loss exit code to the tab-level handler', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(createPane(1) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string, exitCode?: number) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + + onPtyExit?.('tab-pty', -1) + + // A synthetic exit is not proof that the remote process ended. Keep the + // persisted binding and let the tab-level handler mark it unverifiable. + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty', -1) + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('keeps a mounted split pane binding across a synthetic host-loss exit', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string, exitCode?: number) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + + onPtyExit?.('pty-pane-2', -1) + + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('pty-pane-2', -1) + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('tears down the sole terminal when a freshly-spawned PTY exits after the user typed input', async () => { + // Why: an explicit `exit` (or any typed input) is a deliberate close, not a failed-startup shell, so the worktree should deactivate as before. + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('tab-pty') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect(onPtySpawn).toBeTypeOf('function') + expect(onPtyExit).toBeTypeOf('function') + + onPtySpawn?.('tab-pty') + sendTerminalInputThroughPane(pane, 'exit\r') + onPtyExit?.('tab-pty') + + expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty', 0) + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('keeps a failed local terminal visible after user input', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('tab-pty') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ onPaneProcessDied: vi.fn() }) + + connectPanePty(pane as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string, exitCode?: number) => void) + | undefined + + sendTerminalInputThroughPane(pane, 'agent startup\r') + onPtyExit?.('tab-pty', 1) + + expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ + paneId: 1, + exitCode: 1, + startup: null, + reason: 'process-failed' + }) + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('keeps a failed local split pane visible', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ onPaneProcessDied: vi.fn() }) + + connectPanePty(createPane(1) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string, exitCode?: number) => void) + | undefined + onPtyExit?.('tab-pty', 1) + + expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ + paneId: 1, + exitCode: 1, + startup: null, + reason: 'process-failed' + }) + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('classifies the Git Bash capacity failure before retaining its pane', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'tab-pty' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(1) + const startup = { command: 'codex --resume session-1' } + const deps = createDeps({ onPaneProcessDied: vi.fn(), startup }) + + connectPanePty(createPane(1) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string, exitCode?: number) => void) + | undefined + + capturedDataCallback.current?.( + 'console device allocation failure - too many consoles in use, max consoles is 128' + ) + onPtyExit?.('tab-pty', 1) + + expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ + paneId: 1, + exitCode: 1, + startup, + reason: 'git-bash-console-capacity' + }) + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + }) + + it('retains the cold-restore resume startup when its replacement hits capacity', async () => { + const { connectPanePty } = await import('./pty-connection') + const callbacks: ConnectCallbacks[] = [] + const transport = createMockTransport('resume-pty') + transport.connect.mockImplementation(async (options: { callbacks: ConnectCallbacks }) => { + callbacks.push(options.callbacks) + return 'resume-pty' + }) + transportFactoryQueue.push(transport) + const launchConfig = installSleepingCodexResumeState() + const deps = createDeps({ + startup: { command: 'codex stale-startup' }, + onPaneProcessDied: vi.fn() + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + callbacks[0]?.onData?.('too many consoles in use, max consoles is 128') + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string, exitCode?: number) => void) + | undefined + onPtyExit?.('resume-pty', 1) + + expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ + paneId: 1, + exitCode: 1, + startup: expect.objectContaining({ + command: expect.stringContaining("'resume' 'codex-session-1'"), + launchConfig, + resumeProviderSession: { key: 'session_id', id: 'codex-session-1' }, + launchAgent: 'codex', + showSessionRestoredBanner: true + }), + reason: 'git-bash-console-capacity' + }) + }) + + it('does not carry capacity detection into a cold-restore replacement', async () => { + const { connectPanePty } = await import('./pty-connection') + const callbacks: ConnectCallbacks[] = [] + let currentPtyId = 'lost-pty' + const transport = createMockTransport(currentPtyId) + transport.getPtyId.mockImplementation(() => currentPtyId) + transport.connect.mockImplementation( + async (options: { sessionId?: string; callbacks: ConnectCallbacks }) => { + callbacks.push(options.callbacks) + if (options.sessionId) { + options.callbacks.onData?.('too many consoles in use, max consoles is 128') + return { id: currentPtyId, sessionExpired: true } + } + currentPtyId = 'resume-pty' + return currentPtyId + } + ) + transportFactoryQueue.push(transport) + installSleepingCodexResumeState('lost-pty') + const deps = createDeps({ + onPaneProcessDied: vi.fn(), + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(30) + expect(callbacks).toHaveLength(2) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string, exitCode?: number) => void) + | undefined + onPtyExit?.('resume-pty', 1) + + expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ + paneId: 1, + exitCode: 1, + startup: null, + reason: 'process-failed' + }) + }) + + it('tears down the sole terminal when a reattached (not freshly spawned) PTY exits', async () => { + // Why: reattach/coldRestore skip onPtySpawn, so a now-dead previously-live session must still route through onPtyExitRef; the keep-mounted guard is only for brand-new shells. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(createPane(1) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string, exitCode?: number) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + + // No onPtySpawn call: simulates a reattach to a persisted session. + onPtyExit?.('tab-pty') + + expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty', 0) + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('rebinds a provider replacement without granting fresh-spawn exit protection', async () => { + const { connectPanePty } = await import('./pty-connection') + let transportPtyId = 'terminal-old' + const transport = createMockTransport(transportPtyId) + transport.getPtyId = vi.fn(() => transportPtyId) + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + const pane = createPane(1) + + connectPanePty(pane as never, manager as never, deps as never) + const onPtyRebind = createdTransportOptions[0]?.onPtyRebind as + | ((ptyId: string, replacedPtyId: string) => void) + | undefined + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string, exitCode?: number) => void) + | undefined + expect(onPtyRebind).toBeTypeOf('function') + expect(onPtyExit).toBeTypeOf('function') + + transportPtyId = 'terminal-reconnected' + onPtyRebind?.('terminal-reconnected', 'terminal-old') + + expect((transport.getPtyId as unknown as () => string | null)()).toBe('terminal-reconnected') + expect(pane.container.dataset.ptyId).toBe('terminal-reconnected') + onPtyExit?.('terminal-reconnected') + + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'terminal-reconnected') + expect(deps.updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + 'terminal-reconnected', + 'terminal-old' + ) + expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('terminal-reconnected', 0) + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('ignores a late spawn callback after the pane adopted a provider replacement', async () => { + const { connectPanePty } = await import('./pty-connection') + let transportPtyId = 'terminal-old' + const transport = createMockTransport(transportPtyId) + transport.getPtyId = vi.fn(() => transportPtyId) + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + const pane = createPane(1) + + connectPanePty(pane as never, manager as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + const onPtyRebind = createdTransportOptions[0]?.onPtyRebind as + | ((ptyId: string, replacedPtyId: string) => void) + | undefined + expect(onPtySpawn).toBeTypeOf('function') + expect(onPtyRebind).toBeTypeOf('function') + + onPtySpawn?.('terminal-old') + transportPtyId = 'terminal-reconnected' + onPtyRebind?.('terminal-reconnected', 'terminal-old') + + // The fixture dependency is intentionally lightweight, so mirror the live + // tab/layout commit that the real store performs atomically on replacement. + mockStoreState.tabsByWorktree['wt-1'][0]!.ptyId = 'terminal-reconnected' + const replacementLayout = mockStoreState.terminalLayoutsByTabId?.['tab-1'] + if (!replacementLayout) { + throw new Error('test fixture missing terminal layout') + } + replacementLayout.ptyIdsByLeafId![LEAF_1] = 'terminal-reconnected' + + onPtySpawn?.('terminal-old') + + expect(pane.container.dataset.ptyId).toBe('terminal-reconnected') + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenLastCalledWith(1, 'terminal-old') + }) + + it('ignores a late split-pane spawn callback when the tab identity belongs to pane one', async () => { + const { connectPanePty } = await import('./pty-connection') + let transportPtyId = 'terminal-old' + const transport = createMockTransport(transportPtyId) + transport.getPtyId = vi.fn(() => transportPtyId) + transportFactoryQueue.push(transport) + const manager = createManager(2, 2) + const deps = createDeps() + const pane = createPane(2) + mockStoreState.terminalLayoutsByTabId = { + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 }, + ratio: 0.5 + }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'tab-pty', [LEAF_2]: 'terminal-old' } + } + } + + connectPanePty(pane as never, manager as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + const onPtyRebind = createdTransportOptions[0]?.onPtyRebind as + | ((ptyId: string, replacedPtyId: string) => void) + | undefined + expect(onPtySpawn).toBeTypeOf('function') + expect(onPtyRebind).toBeTypeOf('function') + + onPtySpawn?.('terminal-old') + transportPtyId = 'terminal-reconnected' + onPtyRebind?.('terminal-reconnected', 'terminal-old') + + // The tab-level PTY is pane one's fallback; pane two is represented by its leaf binding. + expect(mockStoreState.tabsByWorktree['wt-1'][0]?.ptyId).toBe('tab-pty') + expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId?.[LEAF_2]).toBe( + 'terminal-reconnected' + ) + + onPtySpawn?.('terminal-old') + + expect(pane.container.dataset.ptyId).toBe('terminal-reconnected') + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenLastCalledWith(2, 'terminal-old') + }) + + it('accepts a fresh spawn when the persisted pane still names the retired PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + let transportPtyId = 'terminal-new' + const transport = createMockTransport(transportPtyId) + transport.getPtyId = vi.fn(() => transportPtyId) + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + const pane = createPane(1) + mockStoreState.tabsByWorktree['wt-1'] = [{ id: 'tab-1', ptyId: 'terminal-old' }] + const terminalLayoutsByTabId = + mockStoreState.terminalLayoutsByTabId ?? (mockStoreState.terminalLayoutsByTabId = {}) + terminalLayoutsByTabId['tab-1'] = { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'terminal-old' } + } + + connectPanePty(pane as never, manager as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + expect(onPtySpawn).toBeTypeOf('function') + + onPtySpawn?.('terminal-new') + + expect(pane.container.dataset.ptyId).toBe('terminal-new') + expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'terminal-new', 'terminal-old') + expect(deps.syncPanePtyLayoutBinding).toHaveBeenLastCalledWith(1, 'terminal-new') + }) + + it('closes a split pane when an established PTY exits after output', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-pane-2') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-pane-2' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + expect(capturedDataCallback.current).toBeTypeOf('function') + + capturedDataCallback.current?.('shell prompt') + onPtyExit?.('pty-pane-2') + + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + + it('removes the agent row when an established PTY exits through EOF (#12907)', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + + onPtyExit?.('pty-pane-2') + + expect(mockStoreState.removeAgentStatus).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_2)) + }) + + it('closes a split pane when an established PTY exits after terminal input', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(2) + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + connectPanePty(pane as never, manager as never, deps as never) + const onDataMock = pane.terminal.onData as unknown as { + mock: { calls: [[(data: string) => void] | []] } + } + const terminalInputHandler = onDataMock.mock.calls[0]?.[0] + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect(terminalInputHandler).toBeTypeOf('function') + expect(onPtyExit).toBeTypeOf('function') + + terminalInputHandler?.('exit\r') + onPtyExit?.('pty-pane-2') + + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts deleted file mode 100644 index e1e55661eda..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts +++ /dev/null @@ -1,881 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { sendTerminalInputThroughPane } from './pty-connection-test-dom' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - LEAF_1, - LEAF_2, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -function installSleepingCodexResumeState(restoredPtyId?: string) { - const paneKey = makePaneKey('tab-1', LEAF_1) - const launchConfig = { - agentCommand: "codex '--model' 'gpt-5'", - agentArgs: '--model gpt-5', - agentEnv: { CODEX_PROFILE: 'captured' } - } - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ...(restoredPtyId ? { ptyId: restoredPtyId } : {}) }] - }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1, - launchConfig - } - } - } as StoreState - return launchConfig -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('keeps the surviving split pane mounted when an intentional pane-close PTY exit arrives', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps({ - consumeSuppressedPtyExit: vi.fn(() => true) - }) - - connectPanePty(createPane(2) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - expect(onPtyExit).toBeTypeOf('function') - - onPtyExit?.('pty-pane-2') - - expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2') - expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(2, null) - expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() - expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-pane-2') - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('defers all exit-side state mutation while worktree shutdown verification is pending', async () => { - const { connectPanePty } = await import('./pty-connection') - const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(1) - let pending = true - const deps = createDeps({ - isPtyShutdownPending: vi.fn(() => pending), - consumeSuppressedPtyExit: vi.fn(() => true) - }) - - connectPanePty(createPane(2) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - - onPtyExit?.('pty-pane-2') - - expect(deps.consumeSuppressedPtyExit).not.toHaveBeenCalled() - expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() - expect(deps.clearRuntimePaneTitle).not.toHaveBeenCalled() - expect(deps.clearTabPtyId).not.toHaveBeenCalled() - expect(manager.closePane).not.toHaveBeenCalled() - - pending = false - settleDeferredPtyShutdownExits(['pty-pane-2'], 'committed') - expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2') - expect(deps.clearRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 2) - expect(deps.clearTabPtyId).not.toHaveBeenCalled() - }) - - it('keeps renderer state intact for an exit deferred through rollback', async () => { - const { connectPanePty } = await import('./pty-connection') - const { settleDeferredPtyShutdownExits } = await import('./pty-shutdown-exit-deferral') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(1) - let pending = true - const deps = createDeps({ - isPtyShutdownPending: vi.fn(() => pending), - consumeSuppressedPtyExit: vi.fn(() => true) - }) - - connectPanePty(createPane(2) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - onPtyExit?.('pty-pane-2') - - pending = false - settleDeferredPtyShutdownExits(['pty-pane-2'], 'rolled-back') - - expect(deps.consumeSuppressedPtyExit).not.toHaveBeenCalled() - expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() - expect(deps.clearRuntimePaneTitle).not.toHaveBeenCalled() - expect(deps.clearTabPtyId).not.toHaveBeenCalled() - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('preserves wake identifiers when exit arrives after a committed shutdown', async () => { - const { connectPanePty } = await import('./pty-connection') - const { markCommittedPtyShutdowns } = await import('./pty-shutdown-exit-deferral') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps({ - isPtyShutdownPending: vi.fn(() => false), - consumeSuppressedPtyExit: vi.fn(() => true) - }) - - connectPanePty(createPane(2) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - - markCommittedPtyShutdowns(['pty-pane-2']) - onPtyExit?.('pty-pane-2') - - expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2') - expect(deps.clearTabPtyId).not.toHaveBeenCalled() - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('keeps a fresh split pane mounted when its newborn PTY exits before output or input', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2, 2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }, - clearExitedPanePtyLayoutBinding: vi.fn(() => { - mockStoreState = { - ...mockStoreState, - terminalLayoutsByTabId: { - ...mockStoreState.terminalLayoutsByTabId, - 'tab-1': { - root: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: LEAF_1 }, - second: { type: 'leaf', leafId: LEAF_2 }, - ratio: 0.5 - }, - activeLeafId: LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_1]: 'pty-pane-1' } - } - } - } - }) - }) - - connectPanePty(createPane(2) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - expect(onPtyExit).toBeTypeOf('function') - - onPtyExit?.('pty-pane-2') - - expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-pane-2') - expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-pane-2') - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - expect(manager.closePane).not.toHaveBeenCalled() - expect(manager.setActivePane).toHaveBeenCalledWith(1, { focus: true }) - }) - - it('closes a hidden split pane whose PTY exits before output instead of keeping a ghost', async () => { - // Why (regression, ghost blank pane): a hidden pane's bytes are withheld by the hidden-delivery gate, so "no output" proves nothing — keeping it strands a blank ghost on reveal. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2, 2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - isVisibleRef: { current: false }, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - connectPanePty(createPane(2) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - expect(onPtyExit).toBeTypeOf('function') - - onPtyExit?.('pty-pane-2') - - expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-pane-2') - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - expect(manager.closePane).toHaveBeenCalledWith(2) - }) - - it('keeps a worktree sole terminal mounted when its freshly-spawned PTY exits before input (direnv failure)', async () => { - // Why (regression): a failing .envrc direnv makes the sole terminal's shell exit immediately; routing to onPtyExitRef would close the tab and bounce the user to Landing, so keep it mounted. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(createPane(1) as never, manager as never, deps as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - expect(onPtySpawn).toBeTypeOf('function') - expect(onPtyExit).toBeTypeOf('function') - - // A genuine fresh spawn (onPtySpawn fires only for non-reattach spawns) the user never typed into. - onPtySpawn?.('tab-pty') - onPtyExit?.('tab-pty') - - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('keeps a worktree sole terminal mounted when only a captured shortcut preceded the exit', async () => { - // Why (regression): captured shortcuts refresh the redraw window, but that must not - // count as "the user typed into this pane" — otherwise Shift+Enter before a direnv - // failure closes the tab and bounces the user to Landing. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps() - - const binding = connectPanePty( - createPane(1) as never, - manager as never, - deps as never - ) as unknown as { markShortcutTerminalInputSent: () => void } - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - - onPtySpawn?.('tab-pty') - binding.markShortcutTerminalInputSent() - onPtyExit?.('tab-pty') - - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('forwards a synthetic host-loss exit code to the tab-level handler', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(createPane(1) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - expect(onPtyExit).toBeTypeOf('function') - - onPtyExit?.('tab-pty', -1) - - // A synthetic exit is not proof that the remote process ended. Keep the - // persisted binding and let the tab-level handler mark it unverifiable. - expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() - expect(deps.clearTabPtyId).not.toHaveBeenCalled() - expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty', -1) - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('keeps a mounted split pane binding across a synthetic host-loss exit', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - connectPanePty(createPane(2) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - expect(onPtyExit).toBeTypeOf('function') - - onPtyExit?.('pty-pane-2', -1) - - expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() - expect(deps.clearTabPtyId).not.toHaveBeenCalled() - expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('pty-pane-2', -1) - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('tears down the sole terminal when a freshly-spawned PTY exits after the user typed input', async () => { - // Why: an explicit `exit` (or any typed input) is a deliberate close, not a failed-startup shell, so the worktree should deactivate as before. - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const transport = createMockTransport('tab-pty') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - expect(onPtySpawn).toBeTypeOf('function') - expect(onPtyExit).toBeTypeOf('function') - - onPtySpawn?.('tab-pty') - sendTerminalInputThroughPane(pane, 'exit\r') - onPtyExit?.('tab-pty') - - expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty', 0) - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('keeps a failed local terminal visible after user input', async () => { - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const transport = createMockTransport('tab-pty') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps({ onPaneProcessDied: vi.fn() }) - - connectPanePty(pane as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - - sendTerminalInputThroughPane(pane, 'agent startup\r') - onPtyExit?.('tab-pty', 1) - - expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ - paneId: 1, - exitCode: 1, - startup: null, - reason: 'process-failed' - }) - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('keeps a failed local split pane visible', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ onPaneProcessDied: vi.fn() }) - - connectPanePty(createPane(1) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - onPtyExit?.('tab-pty', 1) - - expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ - paneId: 1, - exitCode: 1, - startup: null, - reason: 'process-failed' - }) - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('classifies the Git Bash capacity failure before retaining its pane', async () => { - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'tab-pty' - }) - transportFactoryQueue.push(transport) - const manager = createManager(1) - const startup = { command: 'codex --resume session-1' } - const deps = createDeps({ onPaneProcessDied: vi.fn(), startup }) - - connectPanePty(createPane(1) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - - capturedDataCallback.current?.( - 'console device allocation failure - too many consoles in use, max consoles is 128' - ) - onPtyExit?.('tab-pty', 1) - - expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ - paneId: 1, - exitCode: 1, - startup, - reason: 'git-bash-console-capacity' - }) - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - }) - - it('retains the cold-restore resume startup when its replacement hits capacity', async () => { - const { connectPanePty } = await import('./pty-connection') - const callbacks: ConnectCallbacks[] = [] - const transport = createMockTransport('resume-pty') - transport.connect.mockImplementation(async (options: { callbacks: ConnectCallbacks }) => { - callbacks.push(options.callbacks) - return 'resume-pty' - }) - transportFactoryQueue.push(transport) - const launchConfig = installSleepingCodexResumeState() - const deps = createDeps({ - startup: { command: 'codex stale-startup' }, - onPaneProcessDied: vi.fn() - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - callbacks[0]?.onData?.('too many consoles in use, max consoles is 128') - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - onPtyExit?.('resume-pty', 1) - - expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ - paneId: 1, - exitCode: 1, - startup: expect.objectContaining({ - command: expect.stringContaining("'resume' 'codex-session-1'"), - launchConfig, - resumeProviderSession: { key: 'session_id', id: 'codex-session-1' }, - launchAgent: 'codex', - showSessionRestoredBanner: true - }), - reason: 'git-bash-console-capacity' - }) - }) - - it('does not carry capacity detection into a cold-restore replacement', async () => { - const { connectPanePty } = await import('./pty-connection') - const callbacks: ConnectCallbacks[] = [] - let currentPtyId = 'lost-pty' - const transport = createMockTransport(currentPtyId) - transport.getPtyId.mockImplementation(() => currentPtyId) - transport.connect.mockImplementation( - async (options: { sessionId?: string; callbacks: ConnectCallbacks }) => { - callbacks.push(options.callbacks) - if (options.sessionId) { - options.callbacks.onData?.('too many consoles in use, max consoles is 128') - return { id: currentPtyId, sessionExpired: true } - } - currentPtyId = 'resume-pty' - return currentPtyId - } - ) - transportFactoryQueue.push(transport) - installSleepingCodexResumeState('lost-pty') - const deps = createDeps({ - onPaneProcessDied: vi.fn(), - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks(30) - expect(callbacks).toHaveLength(2) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - onPtyExit?.('resume-pty', 1) - - expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ - paneId: 1, - exitCode: 1, - startup: null, - reason: 'process-failed' - }) - }) - - it('tears down the sole terminal when a reattached (not freshly spawned) PTY exits', async () => { - // Why: reattach/coldRestore skip onPtySpawn, so a now-dead previously-live session must still route through onPtyExitRef; the keep-mounted guard is only for brand-new shells. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(createPane(1) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - expect(onPtyExit).toBeTypeOf('function') - - // No onPtySpawn call: simulates a reattach to a persisted session. - onPtyExit?.('tab-pty') - - expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty', 0) - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('rebinds a provider replacement without granting fresh-spawn exit protection', async () => { - const { connectPanePty } = await import('./pty-connection') - let transportPtyId = 'terminal-old' - const transport = createMockTransport(transportPtyId) - transport.getPtyId = vi.fn(() => transportPtyId) - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps() - const pane = createPane(1) - - connectPanePty(pane as never, manager as never, deps as never) - const onPtyRebind = createdTransportOptions[0]?.onPtyRebind as - | ((ptyId: string, replacedPtyId: string) => void) - | undefined - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - expect(onPtyRebind).toBeTypeOf('function') - expect(onPtyExit).toBeTypeOf('function') - - transportPtyId = 'terminal-reconnected' - onPtyRebind?.('terminal-reconnected', 'terminal-old') - - expect((transport.getPtyId as unknown as () => string | null)()).toBe('terminal-reconnected') - expect(pane.container.dataset.ptyId).toBe('terminal-reconnected') - onPtyExit?.('terminal-reconnected') - - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'terminal-reconnected') - expect(deps.updateTabPtyId).toHaveBeenCalledWith( - 'tab-1', - 'terminal-reconnected', - 'terminal-old' - ) - expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('terminal-reconnected', 0) - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('ignores a late spawn callback after the pane adopted a provider replacement', async () => { - const { connectPanePty } = await import('./pty-connection') - let transportPtyId = 'terminal-old' - const transport = createMockTransport(transportPtyId) - transport.getPtyId = vi.fn(() => transportPtyId) - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps() - const pane = createPane(1) - - connectPanePty(pane as never, manager as never, deps as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - const onPtyRebind = createdTransportOptions[0]?.onPtyRebind as - | ((ptyId: string, replacedPtyId: string) => void) - | undefined - expect(onPtySpawn).toBeTypeOf('function') - expect(onPtyRebind).toBeTypeOf('function') - - onPtySpawn?.('terminal-old') - transportPtyId = 'terminal-reconnected' - onPtyRebind?.('terminal-reconnected', 'terminal-old') - - // The fixture dependency is intentionally lightweight, so mirror the live - // tab/layout commit that the real store performs atomically on replacement. - mockStoreState.tabsByWorktree['wt-1'][0]!.ptyId = 'terminal-reconnected' - const replacementLayout = mockStoreState.terminalLayoutsByTabId?.['tab-1'] - if (!replacementLayout) { - throw new Error('test fixture missing terminal layout') - } - replacementLayout.ptyIdsByLeafId![LEAF_1] = 'terminal-reconnected' - - onPtySpawn?.('terminal-old') - - expect(pane.container.dataset.ptyId).toBe('terminal-reconnected') - expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenLastCalledWith(1, 'terminal-old') - }) - - it('ignores a late split-pane spawn callback when the tab identity belongs to pane one', async () => { - const { connectPanePty } = await import('./pty-connection') - let transportPtyId = 'terminal-old' - const transport = createMockTransport(transportPtyId) - transport.getPtyId = vi.fn(() => transportPtyId) - transportFactoryQueue.push(transport) - const manager = createManager(2, 2) - const deps = createDeps() - const pane = createPane(2) - mockStoreState.terminalLayoutsByTabId = { - 'tab-1': { - root: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: LEAF_1 }, - second: { type: 'leaf', leafId: LEAF_2 }, - ratio: 0.5 - }, - activeLeafId: LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_1]: 'tab-pty', [LEAF_2]: 'terminal-old' } - } - } - - connectPanePty(pane as never, manager as never, deps as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - const onPtyRebind = createdTransportOptions[0]?.onPtyRebind as - | ((ptyId: string, replacedPtyId: string) => void) - | undefined - expect(onPtySpawn).toBeTypeOf('function') - expect(onPtyRebind).toBeTypeOf('function') - - onPtySpawn?.('terminal-old') - transportPtyId = 'terminal-reconnected' - onPtyRebind?.('terminal-reconnected', 'terminal-old') - - // The tab-level PTY is pane one's fallback; pane two is represented by its leaf binding. - expect(mockStoreState.tabsByWorktree['wt-1'][0]?.ptyId).toBe('tab-pty') - expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId?.[LEAF_2]).toBe( - 'terminal-reconnected' - ) - - onPtySpawn?.('terminal-old') - - expect(pane.container.dataset.ptyId).toBe('terminal-reconnected') - expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenLastCalledWith(2, 'terminal-old') - }) - - it('accepts a fresh spawn when the persisted pane still names the retired PTY', async () => { - const { connectPanePty } = await import('./pty-connection') - let transportPtyId = 'terminal-new' - const transport = createMockTransport(transportPtyId) - transport.getPtyId = vi.fn(() => transportPtyId) - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps() - const pane = createPane(1) - mockStoreState.tabsByWorktree['wt-1'] = [{ id: 'tab-1', ptyId: 'terminal-old' }] - const terminalLayoutsByTabId = - mockStoreState.terminalLayoutsByTabId ?? (mockStoreState.terminalLayoutsByTabId = {}) - terminalLayoutsByTabId['tab-1'] = { - root: { type: 'leaf', leafId: LEAF_1 }, - activeLeafId: LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_1]: 'terminal-old' } - } - - connectPanePty(pane as never, manager as never, deps as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - expect(onPtySpawn).toBeTypeOf('function') - - onPtySpawn?.('terminal-new') - - expect(pane.container.dataset.ptyId).toBe('terminal-new') - expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'terminal-new', 'terminal-old') - expect(deps.syncPanePtyLayoutBinding).toHaveBeenLastCalledWith(1, 'terminal-new') - }) - - it('closes a split pane when an established PTY exits after output', async () => { - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-pane-2') - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-pane-2' - }) - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - connectPanePty(createPane(2) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - expect(onPtyExit).toBeTypeOf('function') - expect(capturedDataCallback.current).toBeTypeOf('function') - - capturedDataCallback.current?.('shell prompt') - onPtyExit?.('pty-pane-2') - - expect(manager.closePane).toHaveBeenCalledWith(2) - }) - - it('removes the agent row when an established PTY exits through EOF (#12907)', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - connectPanePty(createPane(2) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - expect(onPtyExit).toBeTypeOf('function') - - onPtyExit?.('pty-pane-2') - - expect(mockStoreState.removeAgentStatus).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_2)) - }) - - it('closes a split pane when an established PTY exits after terminal input', async () => { - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(2) - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - connectPanePty(pane as never, manager as never, deps as never) - const onDataMock = pane.terminal.onData as unknown as { - mock: { calls: [[(data: string) => void] | []] } - } - const terminalInputHandler = onDataMock.mock.calls[0]?.[0] - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - expect(terminalInputHandler).toBeTypeOf('function') - expect(onPtyExit).toBeTypeOf('function') - - terminalInputHandler?.('exit\r') - onPtyExit?.('pty-pane-2') - - expect(manager.closePane).toHaveBeenCalledWith(2) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.spec.ts new file mode 100644 index 00000000000..351e3fb2358 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.spec.ts @@ -0,0 +1,782 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + POST_REPLAY_DEAD_TUI_RESET, + POST_REPLAY_MODE_RESET, + POST_REPLAY_REATTACH_RESET, + POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, + RESET_GRAPHIC_RENDITION, + RESET_TERMINAL_CURSOR_STYLE +} from '../../../../shared/terminal-mode-reset-profiles' +import { replayEpilogue } from './pty-connection-test-replay-epilogue' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + withMockedDocumentActiveElement, + configureTerminalFocusMode, + sendTerminalInputThroughPane +} from './pty-connection-test-dom' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { + createInitialStoreState, + buildReattachPaneTitleState, + buildActiveRuntimeEnvironmentState +} from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionReattachModeResetTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + function setReattachPaneTitle(title: string): void { + mockStoreState = buildReattachPaneTitleState(mockStoreState, title) + } + + // Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. + function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { + mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('retries a fresh-spawn native follow reset when renderer dimensions return', async () => { + const { connectPanePty } = await import('./pty-connection') + const { getTerminalScrollIntentKind } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('fresh-pty') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] } + } as StoreState + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 40 + pane.terminal.buffer.active.baseY = 100 + const renderListener: { current: (() => void) | null } = { current: null } + const renderDisposable = { dispose: vi.fn() } + pane.terminal.onRender = vi.fn((listener: () => void) => { + renderListener.current = listener + return renderDisposable + }) + vi.mocked(pane.terminal.scrollToBottom) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation(() => { + pane.terminal.buffer.active.viewportY = pane.terminal.buffer.active.baseY + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(pane.terminal.buffer.active.viewportY).toBe(40) + expect(renderListener.current).not.toBeNull() + + renderListener.current?.() + expect(pane.terminal.buffer.active.viewportY).toBe(100) + expect(pane.terminal.scrollToBottom).toHaveBeenCalledTimes(2) + expect(getTerminalScrollIntentKind(pane.terminal)).toBe('followOutput') + expect(renderDisposable.dispose).toHaveBeenCalledTimes(1) + }) + + it('writes the daemon pendingEscapeTailAnsi after the reset on local reattach (#7329)', async () => { + // Why: re-arm the mid-escape tail LAST, after the reattach reset whose ESC would abort it, so a live continuation completes it. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'restored snapshot', + snapshotCols: 80, + snapshotRows: 24, + pendingEscapeTailAnsi: '\x1b[3' + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const tailWriteCall = pane.terminal.write.mock.invocationCallOrder.find( + (_order, index) => pane.terminal.write.mock.calls[index][0] === '\x1b[3' + ) + const resetWriteCall = pane.terminal.write.mock.invocationCallOrder.find((_order, index) => + String(pane.terminal.write.mock.calls[index][0]).includes(POST_REPLAY_REATTACH_RESET) + ) + expect(tailWriteCall).toBeDefined() + expect(resetWriteCall).toBeDefined() + // The dangling tail is written AFTER the reset. + expect(resetWriteCall as number).toBeLessThan(tailWriteCall as number) + }) + + it('answers cursor queries while an account notice blocks user input', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-live') + transportFactoryQueue.push(transport) + mockStoreState.codexRestartNoticeByPtyId = { + 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B' } + } + const pane = createPane(1) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixtures implement the connection's pane, manager and dependency contract. + const args = [pane, createManager(1), createDeps()] as unknown as Parameters< + typeof connectPanePty + > + const binding = connectPanePty(...args) + transport.getPtyId.mockReturnValue('pty-live') + mockStoreState.codexRestartNoticeByPtyId = { + 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B' } + } + sendTerminalInputThroughPane(pane, '\x1b[1;1R') + sendTerminalInputThroughPane(pane, 'do work\r') + expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[1;1R') + expect(transport.sendInput).not.toHaveBeenCalledWith('do work\r', expect.anything()) + binding.dispose() + }) + + it('routes native onData query replies through sendInputImmediate, typed input through sendInput (#7329)', async () => { + // Why this test: the mock aliases sendInputImmediate to sendInput, so other tests can't tell them apart; this pins the routing decision. + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-7329') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + + // xterm answers CSI 6n natively with a CPR via onData; it must take the immediate path (skips the remote 8ms debounce that corrupted it). + sendTerminalInputThroughPane(pane, '\x1b[3;1R') + expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[3;1R') + + // Ordinary typed input must stay on the debounced path — never immediate. + transport.sendInputImmediate.mockClear() + sendTerminalInputThroughPane(pane, 'yes') + sendTerminalInputThroughPane(pane, '\x1b[A') // arrow-key auto-repeat stays batched + expect(transport.sendInput).toHaveBeenCalledWith('yes', 'query-reply') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[A', 'query-reply') + expect(transport.sendInputImmediate).not.toHaveBeenCalled() + + // terminal-query-reply.test proves real xterm emits this as one framed onData reply; this pins it to the immediate path. + transport.sendInputImmediate.mockClear() + const xtversionReply = '\x1bP>|xterm.js(6.1.0-beta.287)\x1b\\' + sendTerminalInputThroughPane(pane, xtversionReply) + expect(transport.sendInputImmediate).toHaveBeenCalledWith(xtversionReply) + + // Printable input is user-owned: remote cooked echo returns via PTY output, not onData, so OSC-looking text stays on normal input. + transport.sendInput.mockClear() + transport.sendInputImmediate.mockClear() + const printableInputs = [']10;hello', '>|xterm.js(6.1.0-beta.287)', ']|literal-text'] + for (const data of printableInputs) { + sendTerminalInputThroughPane(pane, data) + expect(transport.sendInput).toHaveBeenCalledWith(data, 'query-reply') + } + expect(transport.sendInputImmediate).not.toHaveBeenCalled() + }) + + it('writes the onReplayData pendingEscapeTailAnsi meta last, after the replayed bytes (#7329)', async () => { + // Why this test: the onReplayData meta pass-through had no failing test — severing it kept the suite green. + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const pane = createPane(1) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) as typeof pane.terminal.write + const transport = createMockTransport('remote:web-env-1@@pty-7329-tail') + const replayCallback: { + current: + | (( + data: string, + meta?: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + replayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:web-env-1@@pty-7329-tail', replay: '' } + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + expect(replayCallback.current).toBeTypeOf('function') + + replayCallback.current?.('remote snapshot bytes', { + clearBeforeReplay: false, + pendingEscapeTailAnsi: '\x1b[3' + }) + await flushAsyncTicks(20) + + const snapshotIndex = writes.indexOf('remote snapshot bytes') + const tailIndex = writes.lastIndexOf('\x1b[3') + expect(snapshotIndex).toBeGreaterThanOrEqual(0) + expect(tailIndex).toBeGreaterThanOrEqual(0) + // The dangling tail is re-armed after the snapshot/reset, so the next live chunk completes it instead of rendering literally. + expect(tailIndex).toBeGreaterThan(snapshotIndex) + expect(writes.slice(tailIndex + 1)).toEqual([]) + }) + + it('preserves live modes and injects focus-in after focused agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + // Why: public xterm modes plus an agent title are the stable signal for a live focus-driven TUI; avoid private `_core` probes. + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') + // Snapshot ends with ?25l (Cursor Agent parks/hides the cursor); the reset must preserve it, not force ?25h, or a stray block paints. + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(RESET_TERMINAL_CURSOR_STYLE), + expect.any(Function) + ) + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + ([data]) => data as string + ) + expect(writes.some((data) => data.includes('\x1b[?25h'))).toBe(false) + }) + }) + + it('lets fresh host shell proof outrank stale live-agent metadata on reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: '\x1b[?1049h\x1b[?1003hstale agent snapshot', + isAlternateScreen: true, + snapshotTerminalOwner: 'shell' as const + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const manager = createManager(1) + connectPanePty( + pane as never, + manager as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), + expect.any(Function) + ) + }) + + it('ignores the stale agent signal on a cold restore and applies the fresh-shell reset', async () => { + // Why: pane status and title are persisted, so after a cold restore they still + // describe the process that died and make the pane look agent-owned. Preserving + // "its" modes arms mouse/focus/paste reporting against the replacement shell, + // which then prints the reports as junk at the prompt (#12101). + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: '\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + ([data]) => data as string + ) + expect(writes).toContain( + `${RESET_GRAPHIC_RENDITION}\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ` + ) + expect(writes).toContain(replayEpilogue(POST_REPLAY_MODE_RESET, 0)) + expect(writes).not.toContain(replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET)) + }) + }) + + it('applies the fresh-shell reset when a spawn is answered with a cold-restore reattach', async () => { + // Why: main can answer a *spawn* with an adopted session, so the reattach handler + // is reachable by a second door that skips the restored-session path entirely. The + // cold-restore signal has to survive that door too, or #12101 returns on it. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + let activePtyId = 'tab-pty' + transport.getPtyId.mockImplementation(() => activePtyId) + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + throw new Error('restored session is gone') + } + // Main answered the spawn by adopting a durable session instead. + activePtyId = 'adopted-pty' + return { + id: 'adopted-pty', + isReattach: true, + // Keep this snapshot free of ?25l: the live agent reset is built from the + // payload and only equals the constant negated below when the cursor is left + // visible. Ending it hidden would quietly retire that assertion. + snapshot: '\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(30) + + expect(transport.connect).toHaveBeenCalledTimes(2) + expect(transport.connect.mock.calls[0]?.[0]?.sessionId).toBe('tab-pty') + expect(transport.connect.mock.calls[1]?.[0]?.sessionId).toBeUndefined() + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + ([data]) => data as string + ) + const output = writes.join('') + const snapshotIndex = output.indexOf('\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ') + const resetIndex = output.indexOf(replayEpilogue(POST_REPLAY_MODE_RESET, 0)) + expect(snapshotIndex).toBeGreaterThanOrEqual(0) + expect(resetIndex).toBeGreaterThan(snapshotIndex) + expect(writes).toContain(replayEpilogue(POST_REPLAY_MODE_RESET, 0)) + expect(writes).not.toContain(replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET)) + }) + }) + + it('keeps ?25h in the live agent reattach reset when the snapshot leaves the cursor visible', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + // A mid-frame snapshot cut after the TUI re-showed its cursor. + return { + id: sessionId, + snapshot: '\x1b[?1004h\x1b[?25l\x1b[?25hrestored cursor snapshot' + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), + expect.any(Function) + ) + }) + }) + + it('does not inject focus-in after reattach when the terminal does not own DOM focus', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + // Why: a different element owns focus, so the reattach must not send a stray focus-in to a background pane. + await withMockedDocumentActiveElement({}, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(RESET_TERMINAL_CURSOR_STYLE), + expect.any(Function) + ) + }) + }) + + it('resets stale focus and cursor modes for a focused non-agent shell reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('zsh') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_REATTACH_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), + expect.any(Function) + ) + }) + }) + + // Why: issue #8291 — the reattach reset wiped the mouse modes the daemon snapshot had just + // rehydrated, so xterm re-enabled its row-wise selection over a still-running TUI. + const reattachSnapshotResetFor = async (snapshot: string): Promise => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId ? { id: sessionId, snapshot } : null + ) + transportFactoryQueue.push(transport) + setReattachPaneTitle('zsh') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + return withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + return (pane.terminal.write as ReturnType).mock.calls + .map((call) => String(call[0])) + .find( + (data) => + data === replayEpilogue(POST_REPLAY_REATTACH_RESET) || + data === replayEpilogue(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE) + ) + }) + } + + it('keeps mouse reporting when a reattach snapshot restores a live alternate-screen TUI', async () => { + await expect( + reattachSnapshotResetFor('\x1b[?1049h\x1b[?1002h\x1b[?1006hthird-party tui session') + ).resolves.toBe(replayEpilogue(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE)) + }) + + it('still disarms mouse reporting when a reattach snapshot ends on the normal buffer', async () => { + await expect( + reattachSnapshotResetFor('\x1b[?1003h\x1b[?1006hdead tui residue') + ).resolves.toBe(replayEpilogue(POST_REPLAY_REATTACH_RESET)) + }) + + it('does not treat persisted tab launchAgent metadata as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + // Why: launchAgent is launch-ownership metadata that never decays; it must not preserve stale modes for a shell left after agent death. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title: 'zsh', launchAgent: 'claude' }] + }, + runtimePaneTitlesByTabId: { + 'tab-1': { 1: 'zsh' } + } + } as StoreState + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_REATTACH_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), + expect.any(Function) + ) + }) + }) + + it('does not treat an agent-name token in a shell title as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + // Why: broad token matching would classify this ordinary ssh title as an agent and preserve stale modes into a bare shell. + setReattachPaneTitle('ssh devin@host') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_REATTACH_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), + expect.any(Function) + ) + }) + }) + + it('does not treat ordinary shell scrollback mentioning Cursor Agent as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: + '\x1b[?1004h\x1b[?25l$ grep -R "Cursor Agent" docs\r\nCursor Agent IME notes\r\n' + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('zsh') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_REATTACH_RESET), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), + expect.any(Function) + ) + }) + }) + + it('resets an already-idle agent cursor again after reattach SIGWINCH repaint', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: 'restored idle codex snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title: 'Codex done' }] + }, + runtimePaneTitlesByTabId: { + 'tab-1': { 1: 'Codex done' } + }, + settings: { + ...mockStoreState.settings + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(window.api.pty.signal).toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + expect(pane.terminal.write).not.toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + + await new Promise((resolve) => setTimeout(resolve, 300)) + + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + }) + + // Why: a reattach with both snapshot and replay paints the same tail twice (dup-TUI-output on switch); snapshot wins by precedence. + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts deleted file mode 100644 index c57c1bd9e9d..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts +++ /dev/null @@ -1,868 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, - POST_REPLAY_DEAD_TUI_RESET, - POST_REPLAY_MODE_RESET, - POST_REPLAY_REATTACH_RESET, - POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, - RESET_GRAPHIC_RENDITION, - RESET_TERMINAL_CURSOR_STYLE -} from '../../../../shared/terminal-mode-reset-profiles' -import { replayEpilogue } from './pty-connection-test-replay-epilogue' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - withMockedDocumentActiveElement, - configureTerminalFocusMode, - sendTerminalInputThroughPane -} from './pty-connection-test-dom' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { - createInitialStoreState, - buildReattachPaneTitleState, - buildActiveRuntimeEnvironmentState -} from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -function setReattachPaneTitle(title: string): void { - mockStoreState = buildReattachPaneTitleState(mockStoreState, title) -} - -// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. -function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { - mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('retries a fresh-spawn native follow reset when renderer dimensions return', async () => { - const { connectPanePty } = await import('./pty-connection') - const { getTerminalScrollIntentKind } = - await import('@/lib/pane-manager/terminal-scroll-intent') - const transport = createMockTransport('fresh-pty') - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] } - } as StoreState - const pane = createPane(1) - pane.terminal.buffer.active.viewportY = 40 - pane.terminal.buffer.active.baseY = 100 - const renderListener: { current: (() => void) | null } = { current: null } - const renderDisposable = { dispose: vi.fn() } - pane.terminal.onRender = vi.fn((listener: () => void) => { - renderListener.current = listener - return renderDisposable - }) - vi.mocked(pane.terminal.scrollToBottom) - .mockImplementationOnce(() => { - throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") - }) - .mockImplementation(() => { - pane.terminal.buffer.active.viewportY = pane.terminal.buffer.active.baseY - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - expect(pane.terminal.buffer.active.viewportY).toBe(40) - expect(renderListener.current).not.toBeNull() - - renderListener.current?.() - expect(pane.terminal.buffer.active.viewportY).toBe(100) - expect(pane.terminal.scrollToBottom).toHaveBeenCalledTimes(2) - expect(getTerminalScrollIntentKind(pane.terminal)).toBe('followOutput') - expect(renderDisposable.dispose).toHaveBeenCalledTimes(1) - }) - - it('writes the daemon pendingEscapeTailAnsi after the reset on local reattach (#7329)', async () => { - // Why: re-arm the mid-escape tail LAST, after the reattach reset whose ESC would abort it, so a live continuation completes it. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: sessionId, - snapshot: 'restored snapshot', - snapshotCols: 80, - snapshotRows: 24, - pendingEscapeTailAnsi: '\x1b[3' - } - } - return null - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - const tailWriteCall = pane.terminal.write.mock.invocationCallOrder.find( - (_order, index) => pane.terminal.write.mock.calls[index][0] === '\x1b[3' - ) - const resetWriteCall = pane.terminal.write.mock.invocationCallOrder.find((_order, index) => - String(pane.terminal.write.mock.calls[index][0]).includes(POST_REPLAY_REATTACH_RESET) - ) - expect(tailWriteCall).toBeDefined() - expect(resetWriteCall).toBeDefined() - // The dangling tail is written AFTER the reset. - expect(resetWriteCall as number).toBeLessThan(tailWriteCall as number) - }) - - it('answers cursor queries while an account notice blocks user input', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-live') - transportFactoryQueue.push(transport) - mockStoreState.codexRestartNoticeByPtyId = { - 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B' } - } - const pane = createPane(1) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixtures implement the connection's pane, manager and dependency contract. - const args = [pane, createManager(1), createDeps()] as unknown as Parameters< - typeof connectPanePty - > - const binding = connectPanePty(...args) - transport.getPtyId.mockReturnValue('pty-live') - mockStoreState.codexRestartNoticeByPtyId = { - 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B' } - } - sendTerminalInputThroughPane(pane, '\x1b[1;1R') - sendTerminalInputThroughPane(pane, 'do work\r') - expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[1;1R') - expect(transport.sendInput).not.toHaveBeenCalledWith('do work\r', expect.anything()) - binding.dispose() - }) - - it('routes native onData query replies through sendInputImmediate, typed input through sendInput (#7329)', async () => { - // Why this test: the mock aliases sendInputImmediate to sendInput, so other tests can't tell them apart; this pins the routing decision. - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const pane = createPane(1) - const transport = createMockTransport('remote:web-env-1@@pty-7329') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - - // xterm answers CSI 6n natively with a CPR via onData; it must take the immediate path (skips the remote 8ms debounce that corrupted it). - sendTerminalInputThroughPane(pane, '\x1b[3;1R') - expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[3;1R') - - // Ordinary typed input must stay on the debounced path — never immediate. - transport.sendInputImmediate.mockClear() - sendTerminalInputThroughPane(pane, 'yes') - sendTerminalInputThroughPane(pane, '\x1b[A') // arrow-key auto-repeat stays batched - expect(transport.sendInput).toHaveBeenCalledWith('yes', 'query-reply') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[A', 'query-reply') - expect(transport.sendInputImmediate).not.toHaveBeenCalled() - - // terminal-query-reply.test proves real xterm emits this as one framed onData reply; this pins it to the immediate path. - transport.sendInputImmediate.mockClear() - const xtversionReply = '\x1bP>|xterm.js(6.1.0-beta.287)\x1b\\' - sendTerminalInputThroughPane(pane, xtversionReply) - expect(transport.sendInputImmediate).toHaveBeenCalledWith(xtversionReply) - - // Printable input is user-owned: remote cooked echo returns via PTY output, not onData, so OSC-looking text stays on normal input. - transport.sendInput.mockClear() - transport.sendInputImmediate.mockClear() - const printableInputs = [']10;hello', '>|xterm.js(6.1.0-beta.287)', ']|literal-text'] - for (const data of printableInputs) { - sendTerminalInputThroughPane(pane, data) - expect(transport.sendInput).toHaveBeenCalledWith(data, 'query-reply') - } - expect(transport.sendInputImmediate).not.toHaveBeenCalled() - }) - - it('writes the onReplayData pendingEscapeTailAnsi meta last, after the replayed bytes (#7329)', async () => { - // Why this test: the onReplayData meta pass-through had no failing test — severing it kept the suite green. - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const pane = createPane(1) - const writes: string[] = [] - pane.terminal.write = vi.fn((data: string, callback?: () => void) => { - writes.push(data) - callback?.() - }) as typeof pane.terminal.write - const transport = createMockTransport('remote:web-env-1@@pty-7329-tail') - const replayCallback: { - current: - | (( - data: string, - meta?: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } - ) => void) - | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - replayCallback.current = callbacks.onReplayData ?? null - return { id: 'remote:web-env-1@@pty-7329-tail', replay: '' } - }) - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - expect(replayCallback.current).toBeTypeOf('function') - - replayCallback.current?.('remote snapshot bytes', { - clearBeforeReplay: false, - pendingEscapeTailAnsi: '\x1b[3' - }) - await flushAsyncTicks(20) - - const snapshotIndex = writes.indexOf('remote snapshot bytes') - const tailIndex = writes.lastIndexOf('\x1b[3') - expect(snapshotIndex).toBeGreaterThanOrEqual(0) - expect(tailIndex).toBeGreaterThanOrEqual(0) - // The dangling tail is re-armed after the snapshot/reset, so the next live chunk completes it instead of rendering literally. - expect(tailIndex).toBeGreaterThan(snapshotIndex) - expect(writes.slice(tailIndex + 1)).toEqual([]) - }) - - it('preserves live modes and injects focus-in after focused agent reattach', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } - } - return null - }) - transportFactoryQueue.push(transport) - setReattachPaneTitle('Cursor Agent') - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - // Why: public xterm modes plus an agent title are the stable signal for a live focus-driven TUI; avoid private `_core` probes. - configureTerminalFocusMode(pane, textarea) - await withMockedDocumentActiveElement(textarea, async () => { - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') - // Snapshot ends with ?25l (Cursor Agent parks/hides the cursor); the reset must preserve it, not force ?25h, or a stray block paints. - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(RESET_TERMINAL_CURSOR_STYLE), - expect.any(Function) - ) - const writes = (pane.terminal.write as ReturnType).mock.calls.map( - ([data]) => data as string - ) - expect(writes.some((data) => data.includes('\x1b[?25h'))).toBe(false) - }) - }) - - it('lets fresh host shell proof outrank stale live-agent metadata on reattach', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: sessionId, - snapshot: '\x1b[?1049h\x1b[?1003hstale agent snapshot', - isAlternateScreen: true, - snapshotTerminalOwner: 'shell' as const - } - } - return null - }) - transportFactoryQueue.push(transport) - setReattachPaneTitle('Cursor Agent') - - const pane = createPane(1) - const manager = createManager(1) - connectPanePty( - pane as never, - manager as never, - createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) as never - ) - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), - expect.any(Function) - ) - }) - - it('ignores the stale agent signal on a cold restore and applies the fresh-shell reset', async () => { - // Why: pane status and title are persisted, so after a cold restore they still - // describe the process that died and make the pane look agent-owned. Preserving - // "its" modes arms mouse/focus/paste reporting against the replacement shell, - // which then prints the reports as junk at the prompt (#12101). - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: sessionId, - snapshot: '\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ', - coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } - } - } - return null - }) - transportFactoryQueue.push(transport) - setReattachPaneTitle('Cursor Agent') - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - await withMockedDocumentActiveElement(textarea, async () => { - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - const writes = (pane.terminal.write as ReturnType).mock.calls.map( - ([data]) => data as string - ) - expect(writes).toContain( - `${RESET_GRAPHIC_RENDITION}\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ` - ) - expect(writes).toContain(replayEpilogue(POST_REPLAY_MODE_RESET, 0)) - expect(writes).not.toContain(replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET)) - }) - }) - - it('applies the fresh-shell reset when a spawn is answered with a cold-restore reattach', async () => { - // Why: main can answer a *spawn* with an adopted session, so the reattach handler - // is reachable by a second door that skips the restored-session path entirely. The - // cold-restore signal has to survive that door too, or #12101 returns on it. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - let activePtyId = 'tab-pty' - transport.getPtyId.mockImplementation(() => activePtyId) - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - throw new Error('restored session is gone') - } - // Main answered the spawn by adopting a durable session instead. - activePtyId = 'adopted-pty' - return { - id: 'adopted-pty', - isReattach: true, - // Keep this snapshot free of ?25l: the live agent reset is built from the - // payload and only equals the constant negated below when the cursor is left - // visible. Ending it hidden would quietly retire that assertion. - snapshot: '\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ', - coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } - } - }) - transportFactoryQueue.push(transport) - setReattachPaneTitle('Cursor Agent') - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - await withMockedDocumentActiveElement(textarea, async () => { - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(30) - - expect(transport.connect).toHaveBeenCalledTimes(2) - expect(transport.connect.mock.calls[0]?.[0]?.sessionId).toBe('tab-pty') - expect(transport.connect.mock.calls[1]?.[0]?.sessionId).toBeUndefined() - const writes = (pane.terminal.write as ReturnType).mock.calls.map( - ([data]) => data as string - ) - const output = writes.join('') - const snapshotIndex = output.indexOf('\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ') - const resetIndex = output.indexOf(replayEpilogue(POST_REPLAY_MODE_RESET, 0)) - expect(snapshotIndex).toBeGreaterThanOrEqual(0) - expect(resetIndex).toBeGreaterThan(snapshotIndex) - expect(writes).toContain(replayEpilogue(POST_REPLAY_MODE_RESET, 0)) - expect(writes).not.toContain(replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET)) - }) - }) - - it('keeps ?25h in the live agent reattach reset when the snapshot leaves the cursor visible', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - // A mid-frame snapshot cut after the TUI re-showed its cursor. - return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25l\x1b[?25hrestored cursor snapshot' } - } - return null - }) - transportFactoryQueue.push(transport) - setReattachPaneTitle('Cursor Agent') - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - await withMockedDocumentActiveElement(textarea, async () => { - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), - expect.any(Function) - ) - }) - }) - - it('does not inject focus-in after reattach when the terminal does not own DOM focus', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } - } - return null - }) - transportFactoryQueue.push(transport) - setReattachPaneTitle('Cursor Agent') - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - // Why: a different element owns focus, so the reattach must not send a stray focus-in to a background pane. - await withMockedDocumentActiveElement({}, async () => { - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(RESET_TERMINAL_CURSOR_STYLE), - expect.any(Function) - ) - }) - }) - - it('resets stale focus and cursor modes for a focused non-agent shell reattach', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } - } - return null - }) - transportFactoryQueue.push(transport) - setReattachPaneTitle('zsh') - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - await withMockedDocumentActiveElement(textarea, async () => { - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_REATTACH_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), - expect.any(Function) - ) - }) - }) - - // Why: issue #8291 — the reattach reset wiped the mouse modes the daemon snapshot had just - // rehydrated, so xterm re-enabled its row-wise selection over a still-running TUI. - const reattachSnapshotResetFor = async (snapshot: string): Promise => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => - sessionId ? { id: sessionId, snapshot } : null - ) - transportFactoryQueue.push(transport) - setReattachPaneTitle('zsh') - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - return withMockedDocumentActiveElement(textarea, async () => { - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - return (pane.terminal.write as ReturnType).mock.calls - .map((call) => String(call[0])) - .find( - (data) => - data === replayEpilogue(POST_REPLAY_REATTACH_RESET) || - data === replayEpilogue(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE) - ) - }) - } - - it('keeps mouse reporting when a reattach snapshot restores a live alternate-screen TUI', async () => { - await expect( - reattachSnapshotResetFor('\x1b[?1049h\x1b[?1002h\x1b[?1006hthird-party tui session') - ).resolves.toBe(replayEpilogue(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE)) - }) - - it('still disarms mouse reporting when a reattach snapshot ends on the normal buffer', async () => { - await expect(reattachSnapshotResetFor('\x1b[?1003h\x1b[?1006hdead tui residue')).resolves.toBe( - replayEpilogue(POST_REPLAY_REATTACH_RESET) - ) - }) - - it('does not treat persisted tab launchAgent metadata as a live agent reattach', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } - } - return null - }) - transportFactoryQueue.push(transport) - // Why: launchAgent is launch-ownership metadata that never decays; it must not preserve stale modes for a shell left after agent death. - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title: 'zsh', launchAgent: 'claude' }] - }, - runtimePaneTitlesByTabId: { - 'tab-1': { 1: 'zsh' } - } - } as StoreState - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - await withMockedDocumentActiveElement(textarea, async () => { - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_REATTACH_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), - expect.any(Function) - ) - }) - }) - - it('does not treat an agent-name token in a shell title as a live agent reattach', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } - } - return null - }) - transportFactoryQueue.push(transport) - // Why: broad token matching would classify this ordinary ssh title as an agent and preserve stale modes into a bare shell. - setReattachPaneTitle('ssh devin@host') - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - await withMockedDocumentActiveElement(textarea, async () => { - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_REATTACH_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), - expect.any(Function) - ) - }) - }) - - it('does not treat ordinary shell scrollback mentioning Cursor Agent as a live agent reattach', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { - id: sessionId, - snapshot: - '\x1b[?1004h\x1b[?25l$ grep -R "Cursor Agent" docs\r\nCursor Agent IME notes\r\n' - } - } - return null - }) - transportFactoryQueue.push(transport) - setReattachPaneTitle('zsh') - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - await withMockedDocumentActiveElement(textarea, async () => { - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_REATTACH_RESET), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), - expect.any(Function) - ) - }) - }) - - it('resets an already-idle agent cursor again after reattach SIGWINCH repaint', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { id: sessionId, snapshot: 'restored idle codex snapshot' } - } - return null - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title: 'Codex done' }] - }, - runtimePaneTitlesByTabId: { - 'tab-1': { 1: 'Codex done' } - }, - settings: { - ...mockStoreState.settings - } - } as StoreState - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - - expect(window.api.pty.signal).toHaveBeenCalledWith('tab-pty', 'SIGWINCH') - expect(pane.terminal.write).not.toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - - await new Promise((resolve) => setTimeout(resolve, 300)) - - expect(pane.terminal.write).toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - }) - - // Why: a reattach with both snapshot and replay paints the same tail twice (dup-TUI-output on switch); snapshot wins by precedence. -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.spec.ts new file mode 100644 index 00000000000..d5d67fefebe --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.spec.ts @@ -0,0 +1,668 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { UUID_RE } from './pty-connection-test-constants' +import { + LEAF_1, + createMockTransport, + createPane, + captureCallbackTerminalWrites, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { + createInitialStoreState, + buildActiveRuntimeEnvironmentState +} from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionRemoteRuntimeAttachTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + // Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. + function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { + mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('keeps terminal UI drawing glyphs on the active renderer', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('⠋ Working ├─ file.ts █ progress \uE0B0 prompt\r\n') + + expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith( + '⠋ Working ├─ file.ts █ progress \uE0B0 prompt\r\n', + expect.any(Function) + ) + }) + + it('reattaches via daemon sessionId when an in-session PTY is live', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-local-detached' }] + }, + settings: { + ...mockStoreState.settings + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'pty-local-detached' }) + ) + expect(transport.attach).not.toHaveBeenCalled() + await flushAsyncTicks() + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-local-detached') + }) + + it('attaches remote runtime PTY handles instead of creating a replacement terminal', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.attach.mockImplementation(({ callbacks }: { callbacks?: ConnectCallbacks }) => { + transport.getPtyId.mockReturnValue('remote:env-1@@terminal-1') + callbacks?.onReplayData?.('restored remote prompt $ ') + callbacks?.onConnect?.() + }) + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'remote:terminal-1' }] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-1' + } + } as StoreState + + const pane = createPane(2) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => callback?.()) + const manager = createManager(2) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + expect(transport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: 'remote:terminal-1' }) + ) + expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') + await vi.waitFor(() => + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith( + 'remote:env-1@@terminal-1' + ) + ) + }) + + it('reports remote renderer readiness only after delayed restore output is parsed', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + let remoteCallbacks: ConnectCallbacks | undefined + transport.attach.mockImplementation(({ callbacks }: { callbacks?: ConnectCallbacks }) => { + transport.getPtyId.mockReturnValue('remote:env-1@@terminal-delayed') + remoteCallbacks = callbacks + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'remote:terminal-delayed' }] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-1' + } + } as StoreState + const pane = createPane(2) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty(pane as never, createManager(2) as never, createDeps() as never) + await flushAsyncTicks() + expect(window.api.pty.reportRendererSerializerReady).not.toHaveBeenCalled() + + remoteCallbacks?.onReplayData?.('delayed restored prompt $ ') + remoteCallbacks?.onConnect?.() + await vi.waitFor(() => expect(parseCallbacks.length).toBeGreaterThan(0)) + expect(window.api.pty.reportRendererSerializerReady).not.toHaveBeenCalled() + + for (let step = 0; step < 10; step += 1) { + await vi.waitFor(() => expect(parseCallbacks.length).toBeGreaterThan(0)) + parseCallbacks.shift()?.() + await flushAsyncTicks() + if (vi.mocked(window.api.pty.reportRendererSerializerReady!).mock.calls.length > 0) { + break + } + } + expect(writes.join('')).toContain('delayed restored prompt $ ') + await vi.waitFor(() => + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith( + 'remote:env-1@@terminal-delayed' + ) + ) + }) + + it('cold-spawns slept remote runtime PTYs instead of reattaching the preserved handle', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment('env-1') + const restoredPtyId = 'remote:env-1@@terminal-1' + const freshPtyId = 'remote:env-1@@terminal-2' + const transport = createMockTransport(freshPtyId) + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks, sessionId }: Record) => { + capturedDataCallback.current = (callbacks as ConnectCallbacks | undefined)?.onData ?? null + if (sessionId) { + throw new Error('slept remote runtime PTYs must not reattach by sessionId') + } + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.(freshPtyId) + const connectCallbacks = callbacks as ConnectCallbacks | undefined + connectCallbacks?.onReplayData?.('shell ready\r\n') + connectCallbacks?.onConnect?.() + return freshPtyId + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId }] + }, + ptyIdsByTabId: { + 'tab-1': [] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-1', + agentCmdOverrides: {} + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(1) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => callback?.()) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + capturedDataCallback.current?.('shell ready\r\n') + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(transport.attach).not.toHaveBeenCalled() + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", + launchAgent: 'codex', + launchConfig: { + agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'", + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: {} + }, + launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)), + env: expect.objectContaining({ + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1', + ORCA_WORKSPACE_ID: 'wt-1', + ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + }) + }) + ) + expect(transport.connect).toHaveBeenCalledWith( + expect.not.objectContaining({ sessionId: expect.any(String) }) + ) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, null) + expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', restoredPtyId) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, freshPtyId) + expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', freshPtyId) + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith(freshPtyId) + expect(transport.sendInput).not.toHaveBeenCalled() + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) + }) + + it('does not let a restored encoded PTY override the current worktree owner', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'remote:env-1@@terminal-1' }] + }, + worktreesByRepo: { + repo1: [ + { + id: 'wt-1', + repoId: 'repo1', + path: '/tmp/wt-1', + displayName: 'feat/notis', + runtimeOwnerEnvironmentId: 'env-2' + } + ] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-2' + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-2', expect.any(Object)) + expect(transport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: 'remote:env-1@@terminal-1' }) + ) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') + }) + + it('routes a paired-web mirrored pane through its session-scoped HUB owner', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const tabId = 'web-terminal-host-tab' + const ptyId = 'remote:hub-web@@terminal-1' + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: tabId, ptyId }] }, + ptyIdsByTabId: { [tabId]: [ptyId] }, + worktreesByRepo: { + repo1: [ + { + id: 'wt-1', + repoId: 'repo1', + path: '/srv/wt-1', + hostId: 'local' + } + ] + }, + repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }] + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + tabId, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId } + }) as never + ) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-web', expect.any(Object)) + expect(createIpcPtyTransport).not.toHaveBeenCalled() + expect(transport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: ptyId }) + ) + }) + + it('uses a paired-web pane owner to disambiguate duplicate HUB projections', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const tabId = 'web-terminal-host-tab' + const ptyId = 'remote:hub-b@@terminal-1' + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: tabId, ptyId }] }, + worktreesByRepo: { + repo1: [ + { id: 'wt-1', repoId: 'repo1', path: '/srv/wt-1', hostId: 'local' }, + { + id: 'wt-1', + repoId: 'repo1', + path: '/srv/wt-1', + hostId: 'ssh:private-target', + runtimeOwnerEnvironmentId: 'hub-b' + } + ] + } + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + tabId, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId } + }) as never + ) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-b', expect.any(Object)) + }) + + it('ignores a stale runtime PTY on an explicitly local non-web pane', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const ptyId = 'remote:stale-hub@@terminal-1' + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] }, + worktreesByRepo: { + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }] + }, + repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }], + settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'stale-hub' } + } as StoreState + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + expect(createIpcPtyTransport).toHaveBeenCalled() + }) + + it('uses the focused runtime only for ownerless mixed-version publications', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + worktreesByRepo: { + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/srv/wt-1' }] + }, + repos: [{ id: 'repo1', connectionId: null }], + settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'legacy-hub' } + } as StoreState + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('legacy-hub', expect.any(Object)) + }) + + it('runs an inline setup terminal locally instead of failing its host closed', async () => { + // Regression (#9994 fallout): the branded ephemeral-setup id resolves to no worktree/repo, so + // the strict owner resolver reported it unresolved and gave the pane the "Workspace identity is + // ambiguous across hosts" error transport instead of a real local PTY. + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const setupWorktreeId = + 'ephemeral-setup-terminal:settings-mobile-emulator-orca-cli-skill-terminal' + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { [setupWorktreeId]: [{ id: 'tab-1', ptyId: null }] }, + worktreesByRepo: { + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }] + }, + repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }] + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ worktreeId: setupWorktreeId }) as never + ) + + expect(createIpcPtyTransport).toHaveBeenCalled() + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + }) + + it('runs an inline setup terminal on the single active runtime for remote skill installs', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const setupWorktreeId = + 'ephemeral-setup-terminal:settings-mobile-emulator-orca-cli-skill-terminal' + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { [setupWorktreeId]: [{ id: 'tab-1', ptyId: null }] }, + worktreesByRepo: { + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }] + }, + repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }], + runtimeEnvironments: [{ id: 'hub-a' }], + settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'hub-a' } + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ worktreeId: setupWorktreeId }) as never + ) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-a', expect.any(Object)) + }) + + it('still spawns locally when the worktree row itself proves a local host', async () => { + // The hydration guard must key off "nothing names the host", not "no repo row" — a + // worktree stamped hostId 'local' is resolved even before its repo merges. + const { connectPanePty } = await import('./pty-connection') + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-local': [{ id: 'tab-1', ptyId: null }] }, + worktreesByRepo: { + repo1: [{ id: 'wt-local', repoId: 'repo1', path: '/tmp/wt-local', hostId: 'local' }] + }, + repos: [] + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ worktreeId: 'wt-local', cwd: '/tmp/wt-local' }) as never + ) + + expect(createIpcPtyTransport).toHaveBeenCalled() + }) + + it('withholds a local spawn while a repo-backed worktree has no hydrated host', async () => { + // Regression: an SSH worktree whose repo row hadn't merged yet resolved to a null + // connectionId and spawned on the local daemon with the remote cwd, so the pane never + // bound a PTY (Docker SSH watcher-isolation timeout). + const { connectPanePty } = await import('./pty-connection') + const { createIpcPtyTransport } = await import('./pty-transport') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-remote': [{ id: 'tab-1', ptyId: null }] }, + // Why: the worktree row exists (so the owner is not "ambiguous") but its repo has + // not landed yet — exactly the window that used to fail open to local. + worktreesByRepo: { + repo1: [{ id: 'wt-remote', repoId: 'repo1', path: '/tmp/orca-docker-relay-perf-repo' }] + }, + repos: [] + } as StoreState + + const deps = createDeps({ + worktreeId: 'wt-remote', + cwd: '/tmp/orca-docker-relay-perf-repo' + }) + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + + expect(createIpcPtyTransport).not.toHaveBeenCalled() + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + }) + + it('keeps the floating terminal local even while a runtime is active', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'global-floating-terminal': [{ id: 'tab-1', ptyId: null }] }, + worktreesByRepo: { + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }] + }, + repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }], + runtimeEnvironments: [{ id: 'hub-a' }], + settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'hub-a' } + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ worktreeId: 'global-floating-terminal' }) as never + ) + + expect(createIpcPtyTransport).toHaveBeenCalled() + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + }) + + it('routes a HUB-owned SSH PTY wake hint through the HUB without direct SSH', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const hostPtyId = 'ssh:hub-private@@pty-2' + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: hostPtyId }] + }, + ptyIdsByTabId: { 'tab-1': [hostPtyId] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: hostPtyId } + } + }, + worktreesByRepo: { + repo1: [ + { + id: 'wt-1', + repoId: 'repo1', + path: '/srv/wt-1', + hostId: 'ssh:hub-private', + runtimeOwnerEnvironmentId: 'hub-env' + } + ] + }, + repos: [ + { + id: 'repo1', + connectionId: 'hub-private', + executionHostId: 'runtime:hub-env' + } + ] + } as StoreState + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: hostPtyId } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-env', expect.any(Object)) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: hostPtyId }) + ) + expect(transport.attach).not.toHaveBeenCalled() + expect(window.api.ssh.connect).not.toHaveBeenCalled() + expect(window.api.ssh.needsPassphrasePrompt).not.toHaveBeenCalled() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.test.ts deleted file mode 100644 index cbd4464e65d..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.test.ts +++ /dev/null @@ -1,755 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { UUID_RE } from './pty-connection-test-constants' -import { - LEAF_1, - createMockTransport, - createPane, - captureCallbackTerminalWrites, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { - createInitialStoreState, - buildActiveRuntimeEnvironmentState -} from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. -function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { - mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('keeps terminal UI drawing glyphs on the active renderer', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('⠋ Working ├─ file.ts █ progress \uE0B0 prompt\r\n') - - expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith( - '⠋ Working ├─ file.ts █ progress \uE0B0 prompt\r\n', - expect.any(Function) - ) - }) - - it('reattaches via daemon sessionId when an in-session PTY is live', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'pty-local-detached' }] - }, - settings: { - ...mockStoreState.settings - } - } as StoreState - - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ sessionId: 'pty-local-detached' }) - ) - expect(transport.attach).not.toHaveBeenCalled() - await flushAsyncTicks() - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-local-detached') - }) - - it('attaches remote runtime PTY handles instead of creating a replacement terminal', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transport.attach.mockImplementation(({ callbacks }: { callbacks?: ConnectCallbacks }) => { - transport.getPtyId.mockReturnValue('remote:env-1@@terminal-1') - callbacks?.onReplayData?.('restored remote prompt $ ') - callbacks?.onConnect?.() - }) - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'remote:terminal-1' }] - }, - settings: { - ...mockStoreState.settings, - activeRuntimeEnvironmentId: 'env-1' - } - } as StoreState - - const pane = createPane(2) - pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => callback?.()) - const manager = createManager(2) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).not.toHaveBeenCalled() - expect(transport.attach).toHaveBeenCalledWith( - expect.objectContaining({ existingPtyId: 'remote:terminal-1' }) - ) - expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') - await vi.waitFor(() => - expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith( - 'remote:env-1@@terminal-1' - ) - ) - }) - - it('reports remote renderer readiness only after delayed restore output is parsed', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - let remoteCallbacks: ConnectCallbacks | undefined - transport.attach.mockImplementation(({ callbacks }: { callbacks?: ConnectCallbacks }) => { - transport.getPtyId.mockReturnValue('remote:env-1@@terminal-delayed') - remoteCallbacks = callbacks - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'remote:terminal-delayed' }] - }, - settings: { - ...mockStoreState.settings, - activeRuntimeEnvironmentId: 'env-1' - } - } as StoreState - const pane = createPane(2) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - - connectPanePty(pane as never, createManager(2) as never, createDeps() as never) - await flushAsyncTicks() - expect(window.api.pty.reportRendererSerializerReady).not.toHaveBeenCalled() - - remoteCallbacks?.onReplayData?.('delayed restored prompt $ ') - remoteCallbacks?.onConnect?.() - await vi.waitFor(() => expect(parseCallbacks.length).toBeGreaterThan(0)) - expect(window.api.pty.reportRendererSerializerReady).not.toHaveBeenCalled() - - for (let step = 0; step < 10; step += 1) { - await vi.waitFor(() => expect(parseCallbacks.length).toBeGreaterThan(0)) - parseCallbacks.shift()?.() - await flushAsyncTicks() - if (vi.mocked(window.api.pty.reportRendererSerializerReady!).mock.calls.length > 0) { - break - } - } - expect(writes.join('')).toContain('delayed restored prompt $ ') - await vi.waitFor(() => - expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith( - 'remote:env-1@@terminal-delayed' - ) - ) - }) - - it('cold-spawns slept remote runtime PTYs instead of reattaching the preserved handle', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment('env-1') - const restoredPtyId = 'remote:env-1@@terminal-1' - const freshPtyId = 'remote:env-1@@terminal-2' - const transport = createMockTransport(freshPtyId) - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks, sessionId }: Record) => { - capturedDataCallback.current = (callbacks as ConnectCallbacks | undefined)?.onData ?? null - if (sessionId) { - throw new Error('slept remote runtime PTYs must not reattach by sessionId') - } - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - onPtySpawn?.(freshPtyId) - const connectCallbacks = callbacks as ConnectCallbacks | undefined - connectCallbacks?.onReplayData?.('shell ready\r\n') - connectCallbacks?.onConnect?.() - return freshPtyId - } - ) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId }] - }, - ptyIdsByTabId: { - 'tab-1': [] - }, - settings: { - ...mockStoreState.settings, - activeRuntimeEnvironmentId: 'env-1', - agentCmdOverrides: {} - }, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - - const pane = createPane(1) - pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => callback?.()) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - capturedDataCallback.current?.('shell ready\r\n') - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(transport.attach).not.toHaveBeenCalled() - expect(transport.connect).toHaveBeenCalledTimes(1) - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ - command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", - launchAgent: 'codex', - launchConfig: { - agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'", - agentArgs: '--dangerously-bypass-approvals-and-sandbox', - agentEnv: {} - }, - launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)), - env: expect.objectContaining({ - ORCA_PANE_KEY: paneKey, - ORCA_TAB_ID: 'tab-1', - ORCA_WORKTREE_ID: 'wt-1', - ORCA_WORKSPACE_ID: 'wt-1', - ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) - }) - }) - ) - expect(transport.connect).toHaveBeenCalledWith( - expect.not.objectContaining({ sessionId: expect.any(String) }) - ) - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, null) - expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', restoredPtyId) - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, freshPtyId) - expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', freshPtyId) - expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith(freshPtyId) - expect(transport.sendInput).not.toHaveBeenCalled() - expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) - }) - - it('does not let a restored encoded PTY override the current worktree owner', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'remote:env-1@@terminal-1' }] - }, - worktreesByRepo: { - repo1: [ - { - id: 'wt-1', - repoId: 'repo1', - path: '/tmp/wt-1', - displayName: 'feat/notis', - runtimeOwnerEnvironmentId: 'env-2' - } - ] - }, - settings: { - ...mockStoreState.settings, - activeRuntimeEnvironmentId: 'env-2' - } - } as StoreState - - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-2', expect.any(Object)) - expect(transport.attach).toHaveBeenCalledWith( - expect.objectContaining({ existingPtyId: 'remote:env-1@@terminal-1' }) - ) - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') - }) - - it('routes a paired-web mirrored pane through its session-scoped HUB owner', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const { createIpcPtyTransport } = await import('./pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const tabId = 'web-terminal-host-tab' - const ptyId = 'remote:hub-web@@terminal-1' - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: tabId, ptyId }] }, - ptyIdsByTabId: { [tabId]: [ptyId] }, - worktreesByRepo: { - repo1: [ - { - id: 'wt-1', - repoId: 'repo1', - path: '/srv/wt-1', - hostId: 'local' - } - ] - }, - repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }] - } as StoreState - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ - tabId, - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: ptyId } - }) as never - ) - - expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-web', expect.any(Object)) - expect(createIpcPtyTransport).not.toHaveBeenCalled() - expect(transport.attach).toHaveBeenCalledWith(expect.objectContaining({ existingPtyId: ptyId })) - }) - - it('uses a paired-web pane owner to disambiguate duplicate HUB projections', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const tabId = 'web-terminal-host-tab' - const ptyId = 'remote:hub-b@@terminal-1' - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: tabId, ptyId }] }, - worktreesByRepo: { - repo1: [ - { id: 'wt-1', repoId: 'repo1', path: '/srv/wt-1', hostId: 'local' }, - { - id: 'wt-1', - repoId: 'repo1', - path: '/srv/wt-1', - hostId: 'ssh:private-target', - runtimeOwnerEnvironmentId: 'hub-b' - } - ] - } - } as StoreState - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ - tabId, - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: ptyId } - }) as never - ) - - expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-b', expect.any(Object)) - }) - - it('ignores a stale runtime PTY on an explicitly local non-web pane', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const { createIpcPtyTransport } = await import('./pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const ptyId = 'remote:stale-hub@@terminal-1' - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] }, - worktreesByRepo: { - repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }] - }, - repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }], - settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'stale-hub' } - } as StoreState - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - - expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() - expect(createIpcPtyTransport).toHaveBeenCalled() - }) - - it('uses the focused runtime only for ownerless mixed-version publications', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - worktreesByRepo: { - repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/srv/wt-1' }] - }, - repos: [{ id: 'repo1', connectionId: null }], - settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'legacy-hub' } - } as StoreState - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - - expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('legacy-hub', expect.any(Object)) - }) - - it('runs an inline setup terminal locally instead of failing its host closed', async () => { - // Regression (#9994 fallout): the branded ephemeral-setup id resolves to no worktree/repo, so - // the strict owner resolver reported it unresolved and gave the pane the "Workspace identity is - // ambiguous across hosts" error transport instead of a real local PTY. - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const { createIpcPtyTransport } = await import('./pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const setupWorktreeId = - 'ephemeral-setup-terminal:settings-mobile-emulator-orca-cli-skill-terminal' - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { [setupWorktreeId]: [{ id: 'tab-1', ptyId: null }] }, - worktreesByRepo: { - repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }] - }, - repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }] - } as StoreState - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ worktreeId: setupWorktreeId }) as never - ) - - expect(createIpcPtyTransport).toHaveBeenCalled() - expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() - }) - - it('runs an inline setup terminal on the single active runtime for remote skill installs', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const setupWorktreeId = - 'ephemeral-setup-terminal:settings-mobile-emulator-orca-cli-skill-terminal' - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { [setupWorktreeId]: [{ id: 'tab-1', ptyId: null }] }, - worktreesByRepo: { - repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }] - }, - repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }], - runtimeEnvironments: [{ id: 'hub-a' }], - settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'hub-a' } - } as StoreState - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ worktreeId: setupWorktreeId }) as never - ) - - expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-a', expect.any(Object)) - }) - - it('still spawns locally when the worktree row itself proves a local host', async () => { - // The hydration guard must key off "nothing names the host", not "no repo row" — a - // worktree stamped hostId 'local' is resolved even before its repo merges. - const { connectPanePty } = await import('./pty-connection') - const { createIpcPtyTransport } = await import('./pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-local': [{ id: 'tab-1', ptyId: null }] }, - worktreesByRepo: { - repo1: [{ id: 'wt-local', repoId: 'repo1', path: '/tmp/wt-local', hostId: 'local' }] - }, - repos: [] - } as StoreState - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ worktreeId: 'wt-local', cwd: '/tmp/wt-local' }) as never - ) - - expect(createIpcPtyTransport).toHaveBeenCalled() - }) - - it('withholds a local spawn while a repo-backed worktree has no hydrated host', async () => { - // Regression: an SSH worktree whose repo row hadn't merged yet resolved to a null - // connectionId and spawned on the local daemon with the remote cwd, so the pane never - // bound a PTY (Docker SSH watcher-isolation timeout). - const { connectPanePty } = await import('./pty-connection') - const { createIpcPtyTransport } = await import('./pty-transport') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-remote': [{ id: 'tab-1', ptyId: null }] }, - // Why: the worktree row exists (so the owner is not "ambiguous") but its repo has - // not landed yet — exactly the window that used to fail open to local. - worktreesByRepo: { - repo1: [{ id: 'wt-remote', repoId: 'repo1', path: '/tmp/orca-docker-relay-perf-repo' }] - }, - repos: [] - } as StoreState - - const deps = createDeps({ - worktreeId: 'wt-remote', - cwd: '/tmp/orca-docker-relay-perf-repo' - }) - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - - expect(createIpcPtyTransport).not.toHaveBeenCalled() - expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() - }) - - it('keeps the floating terminal local even while a runtime is active', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const { createIpcPtyTransport } = await import('./pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'global-floating-terminal': [{ id: 'tab-1', ptyId: null }] }, - worktreesByRepo: { - repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }] - }, - repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }], - runtimeEnvironments: [{ id: 'hub-a' }], - settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'hub-a' } - } as StoreState - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ worktreeId: 'global-floating-terminal' }) as never - ) - - expect(createIpcPtyTransport).toHaveBeenCalled() - expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() - }) - - it('routes a HUB-owned SSH PTY wake hint through the HUB without direct SSH', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const hostPtyId = 'ssh:hub-private@@pty-2' - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: hostPtyId }] - }, - ptyIdsByTabId: { 'tab-1': [hostPtyId] }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_1 }, - activeLeafId: LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_1]: hostPtyId } - } - }, - worktreesByRepo: { - repo1: [ - { - id: 'wt-1', - repoId: 'repo1', - path: '/srv/wt-1', - hostId: 'ssh:hub-private', - runtimeOwnerEnvironmentId: 'hub-env' - } - ] - }, - repos: [ - { - id: 'repo1', - connectionId: 'hub-private', - executionHostId: 'runtime:hub-env' - } - ] - } as StoreState - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: hostPtyId } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-env', expect.any(Object)) - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ sessionId: hostPtyId }) - ) - expect(transport.attach).not.toHaveBeenCalled() - expect(window.api.ssh.connect).not.toHaveBeenCalled() - expect(window.api.ssh.needsPassphrasePrompt).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.spec.ts new file mode 100644 index 00000000000..12d9eedaf06 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.spec.ts @@ -0,0 +1,484 @@ +import { bindPtyConnectionTestMocks, ptyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { temporarilySetNavigatorUserAgent } from './pty-connection-test-dom' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionRendererRiskRepaintTests(): void { + const { resetAndRefreshAllTerminalWebglAtlases, scheduleTerminalWebglAtlasRecovery } = + ptyConnectionTestMocks + + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + function expectNoGlobalAtlasRecovery(): void { + // Why: the removed output path requested recovery 200ms before its global reset ran. + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('does not switch renderers for Arabic output', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('Arabic: السلام عليكم\r\n') + + expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith( + 'Arabic: السلام عليكم\r\n', + expect.any(Function) + ) + }) + + it('does not switch renderers when background SGR is split across PTY chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[48') + expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() + + capturedDataCallback.current?.(';2;52;52;52m codex block \x1b[0m\r\n') + + expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() + }) + + it('does not switch renderers across split background SGR PTY chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[4') + capturedDataCallback.current?.('8;2;52') + expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() + + capturedDataCallback.current?.(';52;52m codex block \x1b[0m\r\n') + + expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() + }) + + it('forces a viewport refresh for foreground Codex-style background redraws', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + + connectPanePty(pane as never, manager as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.( + '\x1b[2J\x1b[H\x1b[48;2;52;52;52m codex block text \x1b[0m\r\n' + ) + + expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + }) + + it('forces a viewport refresh for foreground CJK output without CSI', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('没改什么(护城河)\r\n') + + expect(refresh).toHaveBeenCalledWith(0, 39, true) + }) + + it('refreshes renderer-risk output without clearing the shared glyph atlas', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('没改什么(护城河)\r\n') + expect(refresh).not.toHaveBeenCalled() + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expectNoGlobalAtlasRecovery() + }) + + it('refreshes alternate-screen redraws without clearing the shared glyph atlas', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.( + '\x1b[2J\x1b[H{"name":"eepo"}\r\n\x1b[2;1H{"name":"expo"}\x1b[K' + ) + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expectNoGlobalAtlasRecovery() + } finally { + restoreNavigator() + } + }) + + it('refreshes a foreground rewrite entering alternate screen without an atlas clear', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[HVim package.json') + // Why: xterm switches to the alternate buffer while parsing; the write callback observes the post-parse state. + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expectNoGlobalAtlasRecovery() + } finally { + restoreNavigator() + } + }) + + it('avoids atlas clears when alternate-screen entry splits across chunks', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Why: PTY reads split CSI sequences at arbitrary byte boundaries, so the enter sequence can straddle two onData chunks. + capturedDataCallback.current?.('\x1b[?104') + parseCallback?.() + + capturedDataCallback.current?.('9h\x1b[2J\x1b[H~\x1b[K') + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expectNoGlobalAtlasRecovery() + } finally { + restoreNavigator() + } + }) + + it('avoids atlas clears when a foreground rewrite leaves alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1049l\x1b[?25h') + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expectNoGlobalAtlasRecovery() + } finally { + restoreNavigator() + } + }) + + it('avoids atlas clears when one chunk enters and exits alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[Hpager frame\x1b[K\x1b[?1049l') + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expectNoGlobalAtlasRecovery() + } finally { + restoreNavigator() + } + }) + + it('avoids atlas clears for captured redraw chunks split mid-sequence', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Captured from a real vim session: a 1024-byte PTY read boundary cuts the cursor move \x1b[30;5H into "\x1b[30" + ";5H". + capturedDataCallback.current?.('"rules": {\x1b[29;15H\x1b[K\x1b[30') + parseCallback?.() + + capturedDataCallback.current?.( + ';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h' + ) + parseCallback?.() + expect(refresh).toHaveBeenCalled() + expectNoGlobalAtlasRecovery() + } finally { + restoreNavigator() + } + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts deleted file mode 100644 index 8bc6b3be4b1..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts +++ /dev/null @@ -1,558 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { temporarilySetNavigatorUserAgent } from './pty-connection-test-dom' -import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -function expectNoGlobalAtlasRecovery(): void { - // Why: the removed output path requested recovery 200ms before its global reset ran. - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() - expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('does not switch renderers for Arabic output', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('Arabic: السلام عليكم\r\n') - - expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith( - 'Arabic: السلام عليكم\r\n', - expect.any(Function) - ) - }) - - it('does not switch renderers when background SGR is split across PTY chunks', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[48') - expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() - - capturedDataCallback.current?.(';2;52;52;52m codex block \x1b[0m\r\n') - - expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() - }) - - it('does not switch renderers across split background SGR PTY chunks', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[4') - capturedDataCallback.current?.('8;2;52') - expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() - - capturedDataCallback.current?.(';52;52m codex block \x1b[0m\r\n') - - expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() - }) - - it('forces a viewport refresh for foreground Codex-style background redraws', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - - connectPanePty(pane as never, manager as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[2J\x1b[H\x1b[48;2;52;52;52m codex block text \x1b[0m\r\n') - - expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() - expect(refresh).toHaveBeenCalledWith(0, 39, true) - }) - - it('forces a viewport refresh for foreground CJK output without CSI', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('没改什么(护城河)\r\n') - - expect(refresh).toHaveBeenCalledWith(0, 39, true) - }) - - it('refreshes renderer-risk output without clearing the shared glyph atlas', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - let parseCallback: (() => void) | undefined - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - parseCallback = callback - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('没改什么(护城河)\r\n') - expect(refresh).not.toHaveBeenCalled() - parseCallback?.() - expect(refresh).toHaveBeenCalledWith(0, 39, true) - expectNoGlobalAtlasRecovery() - }) - - it('refreshes alternate-screen redraws without clearing the shared glyph atlas', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' - const refresh = vi.fn() - let parseCallback: (() => void) | undefined - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - parseCallback = callback - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.( - '\x1b[2J\x1b[H{"name":"eepo"}\r\n\x1b[2;1H{"name":"expo"}\x1b[K' - ) - parseCallback?.() - expect(refresh).toHaveBeenCalledWith(0, 39, true) - expectNoGlobalAtlasRecovery() - } finally { - restoreNavigator() - } - }) - - it('refreshes a foreground rewrite entering alternate screen without an atlas clear', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - let parseCallback: (() => void) | undefined - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - parseCallback = callback - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[HVim package.json') - // Why: xterm switches to the alternate buffer while parsing; the write callback observes the post-parse state. - ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' - parseCallback?.() - expect(refresh).toHaveBeenCalledWith(0, 39, true) - expectNoGlobalAtlasRecovery() - } finally { - restoreNavigator() - } - }) - - it('avoids atlas clears when alternate-screen entry splits across chunks', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - let parseCallback: (() => void) | undefined - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - parseCallback = callback - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - // Why: PTY reads split CSI sequences at arbitrary byte boundaries, so the enter sequence can straddle two onData chunks. - capturedDataCallback.current?.('\x1b[?104') - parseCallback?.() - - capturedDataCallback.current?.('9h\x1b[2J\x1b[H~\x1b[K') - ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' - parseCallback?.() - expect(refresh).toHaveBeenCalledWith(0, 39, true) - expectNoGlobalAtlasRecovery() - } finally { - restoreNavigator() - } - }) - - it('avoids atlas clears when a foreground rewrite leaves alternate screen', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' - const refresh = vi.fn() - let parseCallback: (() => void) | undefined - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - parseCallback = callback - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1049l\x1b[?25h') - ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' - parseCallback?.() - expect(refresh).toHaveBeenCalledWith(0, 39, true) - expectNoGlobalAtlasRecovery() - } finally { - restoreNavigator() - } - }) - - it('avoids atlas clears when one chunk enters and exits alternate screen', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - let parseCallback: (() => void) | undefined - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - parseCallback = callback - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[Hpager frame\x1b[K\x1b[?1049l') - parseCallback?.() - expect(refresh).toHaveBeenCalledWith(0, 39, true) - expectNoGlobalAtlasRecovery() - } finally { - restoreNavigator() - } - }) - - it('avoids atlas clears for captured redraw chunks split mid-sequence', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' - const refresh = vi.fn() - let parseCallback: (() => void) | undefined - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - parseCallback = callback - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - // Captured from a real vim session: a 1024-byte PTY read boundary cuts the cursor move \x1b[30;5H into "\x1b[30" + ";5H". - capturedDataCallback.current?.('"rules": {\x1b[29;15H\x1b[K\x1b[30') - parseCallback?.() - - capturedDataCallback.current?.( - ';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h' - ) - parseCallback?.() - expect(refresh).toHaveBeenCalled() - expectNoGlobalAtlasRecovery() - } finally { - restoreNavigator() - } - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.spec.ts new file mode 100644 index 00000000000..6df99cbbbea --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.spec.ts @@ -0,0 +1,722 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + POST_REPLAY_REATTACH_RESET, + RESET_TERMINAL_CURSOR_STYLE +} from '../../../../shared/terminal-mode-reset-profiles' +import { replayEpilogue } from './pty-connection-test-replay-epilogue' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + NORMAL_BUFFER_PROLOGUE, + ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN +} from './pty-connection-test-constants' +import { + withMockedDocumentActiveElement, + configureTerminalFocusMode +} from './pty-connection-test-dom' +import { + createMockTransport, + createPane, + captureCallbackTerminalWrites, + createManager +} from './pty-connection-test-pane-fixtures' +import type { ConnectCallbacks, MockTransport } from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { + createInitialStoreState, + buildReattachPaneTitleState, + buildActiveRuntimeEnvironmentState +} from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionReplayPayloadHandlingTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + function setReattachPaneTitle(title: string): void { + mockStoreState = buildReattachPaneTitleState(mockStoreState, title) + } + + // Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. + function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { + mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('preserves a scrolled-up viewport after hidden-backlog snapshot replay', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + pane.terminal.write.mockImplementation((data: string, callback?: () => void) => { + if (data.includes('snapshot-state')) { + pane.terminal.buffer.active.viewportY = 0 + } + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function)) + expect(pane.terminal.scrollToLine).toHaveBeenCalledWith(42) + disposable.dispose() + }) + + it('cancels a delayed snapshot scroll restore when the pane binding is disposed', async () => { + const { connectPanePty } = await import('./pty-connection') + const { isTerminalScrollIntentRebuildInFlight } = + await import('@/lib/pane-manager/terminal-scroll-intent-rebuild') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(writes).toContain('snapshot-state\r\n') + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(true) + pane.terminal.scrollToLine.mockClear() + disposable.dispose() + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(true) + + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 200 + for (const callback of parseCallbacks) { + callback() + } + await flushAsyncTicks() + + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(false) + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('does not apply a delayed snapshot restore after newer user intent', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(writes).toContain('snapshot-state\r\n') + pane.terminal.buffer.active.viewportY = 200 + pane.terminal.buffer.active.baseY = 200 + markTerminalFollowOutput(pane.terminal) + pane.terminal.scrollToLine.mockClear() + for (const callback of parseCallbacks) { + callback() + } + await flushAsyncTicks() + + // Why: replay completion must not overwrite scroll intent recorded while xterm was still parsing the restored snapshot. + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + disposable.dispose() + }) + + it('rebuilds WebGL after remote buffered replay arrives on an already-open pane', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const deps = createDeps() + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.('remote prompt\r\n$ ') + await flushAsyncTicks(6) + + expect(pane.terminal.write).toHaveBeenCalledWith('remote prompt\r\n$ ', expect.any(Function)) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(manager.rebuildPaneWebgl).toHaveBeenCalledWith(1) + disposable.dispose() + }) + + it('preserves live agent modes when queued replay data carries the Cursor Agent screen', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + } + ) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.(ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN) + await flushAsyncTicks(12) + + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), + expect.any(Function) + ) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') + return connection + }) + disposable.dispose() + }) + + // Why pinned: the classifier strips CSI precisely so a styled header still matches. A future + // "just lastIndexOf the raw bytes" shortcut would pass every other test and silently break this. + it('still detects the Cursor Agent screen when CSI styling splits the header and the marker', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + } + ) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.( + '\x1b[4;3HCursor \x1b[1mAgent\x1b[0m\x1b[9;3H→\x1b[0m Plan, search, build anything' + ) + await flushAsyncTicks(12) + + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), + expect.any(Function) + ) + return connection + }) + disposable.dispose() + }) + + // Why pinned: the scan reads only a bounded tail, so a header buried behind megabytes of + // scrollback describes a finished run, not the current screen. + it('ignores a Cursor Agent screen buried beyond the payload scan tail limit', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + } + ) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.( + `${ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN}${'shell scrollback\r\n'.repeat(20_000)}` + ) + await flushAsyncTicks(12) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + return connection + }) + disposable.dispose() + }) + + it('downgrades a scrollback-only Cursor Agent signal when the parsed viewport shows a shell', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + } + ) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + // Why: a buffer whose visible rows carry no Cursor Agent screen models a shell foreground after a dead run left its screen in scrollback. + Object.assign(pane.terminal.buffer.active, { + cursorX: 2, + getLine: () => undefined + }) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + // A dead run left the cursor hidden; the live-agent reset preserves ?25l, so the veto must re-show the cursor for the shell. + capturedReplayCallback.current?.(`${ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN}\x1b[?25l`) + await flushAsyncTicks(12) + + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(RESET_TERMINAL_CURSOR_STYLE), + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[?25h\x1b[?1004l', + expect.any(Function) + ) + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + return connection + }) + disposable.dispose() + }) + + it('does not clear restored scrollback when eager metadata replay opts out', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const deps = createDeps() + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.('\x1b]0;Restored title\x07', { clearBeforeReplay: false }) + await flushAsyncTicks(6) + + expect(pane.terminal.write).not.toHaveBeenCalledWith( + NORMAL_BUFFER_PROLOGUE, + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b]0;Restored title\x07', + expect.any(Function) + ) + disposable.dispose() + }) + + it('does not write a clear or reset for empty eager metadata replay', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const deps = createDeps() + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.('', { clearBeforeReplay: false }) + await flushAsyncTicks(6) + + expect(pane.terminal.write).not.toHaveBeenCalled() + disposable.dispose() + }) + + it('coalesces remote replay payloads that overlap before parsing starts', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const pendingParses: (() => void)[] = [] + pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { + if (callback) { + pendingParses.push(callback) + } + }) + const manager = createManager(1) + const deps = createDeps() + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.('first replay') + capturedReplayCallback.current?.('second replay') + await flushAsyncTicks(6) + + expect(pane.terminal.write).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenNthCalledWith( + 1, + '\x1b[?2026l\x1b[2J\x1b[3J\x1b[H', + expect.any(Function) + ) + + for (let index = 0; index < 8; index += 1) { + await flushAsyncTicks(2) + pendingParses.shift()?.() + } + await flushAsyncTicks(4) + + expect(pane.terminal.write).not.toHaveBeenCalledWith('first replay', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith('second replay', expect.any(Function)) + expect(manager.rebuildPaneWebgl).toHaveBeenCalledTimes(1) + disposable.dispose() + }) + + it('holds newer live bytes until a later replay frame has fully parsed', async () => { + const { connectPanePty } = await import('./pty-connection') + const { deliverTerminalDataWithDeferredCredit } = + await import('@/lib/pane-manager/terminal-delivery-credit') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-live-order') + const callbacksRef: { + replay: ((data: string) => void) | null + data: ((data: string) => void) | null + } = { replay: null, data: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacksRef.replay = callbacks.onReplayData ?? null + callbacksRef.data = callbacks.onData ?? null + return 'remote:env-1@@terminal-live-order' + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks(8) + + callbacksRef.replay?.('authoritative replay') + await flushAsyncTicks(8) + expect(writes).toEqual(['\x1b[?2026l\x1b[2J\x1b[3J\x1b[H']) + + const acknowledgeLiveFrame = vi.fn() + deliverTerminalDataWithDeferredCredit(acknowledgeLiveFrame, () => { + callbacksRef.data?.('NEWER-LIVE\r\n') + }) + expect(writes).not.toContain('NEWER-LIVE\r\n') + expect(acknowledgeLiveFrame).not.toHaveBeenCalled() + for (let index = 0; index < 12 && parseCallbacks.length > 0; index += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(8) + + const replayIndex = writes.indexOf('authoritative replay') + const resetIndex = writes.indexOf(replayEpilogue(POST_REPLAY_REATTACH_RESET)) + const liveIndex = writes.indexOf('NEWER-LIVE\r\n') + expect(replayIndex).toBeGreaterThan(0) + expect(resetIndex).toBeGreaterThan(replayIndex) + expect(liveIndex).toBeGreaterThan(resetIndex) + expect(acknowledgeLiveFrame).toHaveBeenCalledOnce() + + callbacksRef.replay?.('stalled replay') + await flushAsyncTicks(8) + const acknowledgeDisposedFrame = vi.fn() + deliverTerminalDataWithDeferredCredit(acknowledgeDisposedFrame, () => { + callbacksRef.data?.('LIVE-BEHIND-STALLED-REPLAY') + }) + expect(acknowledgeDisposedFrame).not.toHaveBeenCalled() + binding.dispose() + expect(acknowledgeDisposedFrame).toHaveBeenCalledOnce() + }) + + it('drops a queued relay replay instead of retagging it for a replacement PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-old') + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + replayCallback.current = callbacks.onReplayData ?? null + return 'remote:env-1@@terminal-old' + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks(8) + + replayCallback.current?.('blocking replay') + await flushAsyncTicks(8) + replayCallback.current?.('stale queued replay') + vi.mocked(transport.getPtyId).mockReturnValue('remote:env-1@@terminal-replacement') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('blocking replay') + expect(writes).not.toContain('stale queued replay') + binding.dispose() + }) + + it('requests snapshot recovery for one oversized live frame deferred by replay', async () => { + const { connectPanePty } = await import('./pty-connection') + const { deliverTerminalDataWithDeferredCredit } = + await import('@/lib/pane-manager/terminal-delivery-credit') + const transport = createMockTransport('pty-large-live') + const callbacksRef: { + replay: ((data: string) => void) | null + data: ((data: string) => void) | null + } = { replay: null, data: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacksRef.replay = callbacks.onReplayData ?? null + callbacksRef.data = callbacks.onData ?? null + return 'pty-large-live' + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks(8) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue(null) + getMainBufferSnapshot.mockClear() + + callbacksRef.replay?.('authoritative replay') + await flushAsyncTicks(8) + const oversizedLiveFrame = 'L'.repeat(512 * 1024 + 1) + const acknowledgeDroppedFrame = vi.fn() + deliverTerminalDataWithDeferredCredit(acknowledgeDroppedFrame, () => { + callbacksRef.data?.(oversizedLiveFrame) + }) + expect(acknowledgeDroppedFrame).not.toHaveBeenCalled() + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-large-live', { + scrollbackRows: 5000 + }) + expect(writes.some((write) => write.startsWith('L'))).toBe(false) + expect(acknowledgeDroppedFrame).toHaveBeenCalledOnce() + binding.dispose() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts deleted file mode 100644 index 698b0ee1661..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts +++ /dev/null @@ -1,770 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, - POST_REPLAY_REATTACH_RESET, - RESET_TERMINAL_CURSOR_STYLE -} from '../../../../shared/terminal-mode-reset-profiles' -import { replayEpilogue } from './pty-connection-test-replay-epilogue' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - NORMAL_BUFFER_PROLOGUE, - ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN -} from './pty-connection-test-constants' -import { - withMockedDocumentActiveElement, - configureTerminalFocusMode -} from './pty-connection-test-dom' -import { - createMockTransport, - createPane, - captureCallbackTerminalWrites, - createManager -} from './pty-connection-test-pane-fixtures' -import type { ConnectCallbacks, MockTransport } from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { - createInitialStoreState, - buildReattachPaneTitleState, - buildActiveRuntimeEnvironmentState -} from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -function setReattachPaneTitle(title: string): void { - mockStoreState = buildReattachPaneTitleState(mockStoreState, title) -} - -// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. -function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { - mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('preserves a scrolled-up viewport after hidden-backlog snapshot replay', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const live = 'visible-after\r\n' - getMainBufferSnapshot.mockResolvedValue({ - data: 'snapshot-state\r\n', - cols: 100, - rows: 30, - seq: hidden.length + live.length - }) - - const pane = createPane(1) - pane.terminal.buffer.active.viewportY = 42 - pane.terminal.buffer.active.baseY = 100 - pane.terminal.write.mockImplementation((data: string, callback?: () => void) => { - if (data.includes('snapshot-state')) { - pane.terminal.buffer.active.viewportY = 0 - } - callback?.() - }) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function)) - expect(pane.terminal.scrollToLine).toHaveBeenCalledWith(42) - disposable.dispose() - }) - - it('cancels a delayed snapshot scroll restore when the pane binding is disposed', async () => { - const { connectPanePty } = await import('./pty-connection') - const { isTerminalScrollIntentRebuildInFlight } = - await import('@/lib/pane-manager/terminal-scroll-intent-rebuild') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const live = 'visible-after\r\n' - getMainBufferSnapshot.mockResolvedValue({ - data: 'snapshot-state\r\n', - cols: 100, - rows: 30, - seq: hidden.length + live.length - }) - - const pane = createPane(1) - pane.terminal.buffer.active.viewportY = 42 - pane.terminal.buffer.active.baseY = 100 - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ isVisibleRef: { current: false } }) - const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - - expect(writes).toContain('snapshot-state\r\n') - expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(true) - pane.terminal.scrollToLine.mockClear() - disposable.dispose() - expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(true) - - pane.terminal.buffer.active.baseY = 200 - pane.terminal.buffer.active.viewportY = 200 - for (const callback of parseCallbacks) { - callback() - } - await flushAsyncTicks() - - expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(false) - expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() - }) - - it('does not apply a delayed snapshot restore after newer user intent', async () => { - const { connectPanePty } = await import('./pty-connection') - const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const live = 'visible-after\r\n' - getMainBufferSnapshot.mockResolvedValue({ - data: 'snapshot-state\r\n', - cols: 100, - rows: 30, - seq: hidden.length + live.length - }) - - const pane = createPane(1) - pane.terminal.buffer.active.viewportY = 42 - pane.terminal.buffer.active.baseY = 100 - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ isVisibleRef: { current: false } }) - const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - - expect(writes).toContain('snapshot-state\r\n') - pane.terminal.buffer.active.viewportY = 200 - pane.terminal.buffer.active.baseY = 200 - markTerminalFollowOutput(pane.terminal) - pane.terminal.scrollToLine.mockClear() - for (const callback of parseCallbacks) { - callback() - } - await flushAsyncTicks() - - // Why: replay completion must not overwrite scroll intent recorded while xterm was still parsing the restored snapshot. - expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() - disposable.dispose() - }) - - it('rebuilds WebGL after remote buffered replay arrives on an already-open pane', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const transport = createMockTransport('remote:env-1@@terminal-1') - const capturedReplayCallback: { - current: ((data: string) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedReplayCallback.current = callbacks.onReplayData ?? null - return { id: 'remote:env-1@@terminal-1', replay: '' } - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - const manager = createManager(1) - const deps = createDeps() - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedReplayCallback.current?.('remote prompt\r\n$ ') - await flushAsyncTicks(6) - - expect(pane.terminal.write).toHaveBeenCalledWith('remote prompt\r\n$ ', expect.any(Function)) - expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(manager.rebuildPaneWebgl).toHaveBeenCalledWith(1) - disposable.dispose() - }) - - it('preserves live agent modes when queued replay data carries the Cursor Agent screen', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const transport = createMockTransport('remote:env-1@@terminal-1') - const capturedReplayCallback: { - current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedReplayCallback.current = callbacks.onReplayData ?? null - return { id: 'remote:env-1@@terminal-1', replay: '' } - }) - transportFactoryQueue.push(transport) - setReattachPaneTitle('renamed shell') - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - const manager = createManager(1) - const deps = createDeps() - const disposable = await withMockedDocumentActiveElement(textarea, async () => { - const connection = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedReplayCallback.current?.(ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN) - await flushAsyncTicks(12) - - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), - expect.any(Function) - ) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') - return connection - }) - disposable.dispose() - }) - - // Why pinned: the classifier strips CSI precisely so a styled header still matches. A future - // "just lastIndexOf the raw bytes" shortcut would pass every other test and silently break this. - it('still detects the Cursor Agent screen when CSI styling splits the header and the marker', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const transport = createMockTransport('remote:env-1@@terminal-1') - const capturedReplayCallback: { - current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedReplayCallback.current = callbacks.onReplayData ?? null - return { id: 'remote:env-1@@terminal-1', replay: '' } - }) - transportFactoryQueue.push(transport) - setReattachPaneTitle('renamed shell') - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - const manager = createManager(1) - const deps = createDeps() - const disposable = await withMockedDocumentActiveElement(textarea, async () => { - const connection = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedReplayCallback.current?.( - '\x1b[4;3HCursor \x1b[1mAgent\x1b[0m\x1b[9;3H→\x1b[0m Plan, search, build anything' - ) - await flushAsyncTicks(12) - - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), - expect.any(Function) - ) - return connection - }) - disposable.dispose() - }) - - // Why pinned: the scan reads only a bounded tail, so a header buried behind megabytes of - // scrollback describes a finished run, not the current screen. - it('ignores a Cursor Agent screen buried beyond the payload scan tail limit', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const transport = createMockTransport('remote:env-1@@terminal-1') - const capturedReplayCallback: { - current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedReplayCallback.current = callbacks.onReplayData ?? null - return { id: 'remote:env-1@@terminal-1', replay: '' } - }) - transportFactoryQueue.push(transport) - setReattachPaneTitle('renamed shell') - - const pane = createPane(1) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - const manager = createManager(1) - const deps = createDeps() - const disposable = await withMockedDocumentActiveElement(textarea, async () => { - const connection = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedReplayCallback.current?.( - `${ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN}${'shell scrollback\r\n'.repeat(20_000)}` - ) - await flushAsyncTicks(12) - - expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') - return connection - }) - disposable.dispose() - }) - - it('downgrades a scrollback-only Cursor Agent signal when the parsed viewport shows a shell', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const transport = createMockTransport('remote:env-1@@terminal-1') - const capturedReplayCallback: { - current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedReplayCallback.current = callbacks.onReplayData ?? null - return { id: 'remote:env-1@@terminal-1', replay: '' } - }) - transportFactoryQueue.push(transport) - setReattachPaneTitle('renamed shell') - - const pane = createPane(1) - // Why: a buffer whose visible rows carry no Cursor Agent screen models a shell foreground after a dead run left its screen in scrollback. - Object.assign(pane.terminal.buffer.active, { - cursorX: 2, - getLine: () => undefined - }) - const textarea = {} as HTMLTextAreaElement - configureTerminalFocusMode(pane, textarea) - const manager = createManager(1) - const deps = createDeps() - const disposable = await withMockedDocumentActiveElement(textarea, async () => { - const connection = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - // A dead run left the cursor hidden; the live-agent reset preserves ?25l, so the veto must re-show the cursor for the shell. - capturedReplayCallback.current?.(`${ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN}\x1b[?25l`) - await flushAsyncTicks(12) - - expect(pane.terminal.write).toHaveBeenCalledWith( - replayEpilogue(RESET_TERMINAL_CURSOR_STYLE), - expect.any(Function) - ) - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?25h\x1b[?1004l', expect.any(Function)) - expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') - return connection - }) - disposable.dispose() - }) - - it('does not clear restored scrollback when eager metadata replay opts out', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedReplayCallback: { - current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedReplayCallback.current = callbacks.onReplayData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - const manager = createManager(1) - const deps = createDeps() - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedReplayCallback.current?.('\x1b]0;Restored title\x07', { clearBeforeReplay: false }) - await flushAsyncTicks(6) - - expect(pane.terminal.write).not.toHaveBeenCalledWith( - NORMAL_BUFFER_PROLOGUE, - expect.any(Function) - ) - expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b]0;Restored title\x07', - expect.any(Function) - ) - disposable.dispose() - }) - - it('does not write a clear or reset for empty eager metadata replay', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedReplayCallback: { - current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedReplayCallback.current = callbacks.onReplayData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - const manager = createManager(1) - const deps = createDeps() - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedReplayCallback.current?.('', { clearBeforeReplay: false }) - await flushAsyncTicks(6) - - expect(pane.terminal.write).not.toHaveBeenCalled() - disposable.dispose() - }) - - it('coalesces remote replay payloads that overlap before parsing starts', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const transport = createMockTransport('remote:env-1@@terminal-1') - const capturedReplayCallback: { - current: ((data: string) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedReplayCallback.current = callbacks.onReplayData ?? null - return { id: 'remote:env-1@@terminal-1', replay: '' } - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const pendingParses: (() => void)[] = [] - pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { - if (callback) { - pendingParses.push(callback) - } - }) - const manager = createManager(1) - const deps = createDeps() - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedReplayCallback.current?.('first replay') - capturedReplayCallback.current?.('second replay') - await flushAsyncTicks(6) - - expect(pane.terminal.write).toHaveBeenCalledTimes(1) - expect(pane.terminal.write).toHaveBeenNthCalledWith( - 1, - '\x1b[?2026l\x1b[2J\x1b[3J\x1b[H', - expect.any(Function) - ) - - for (let index = 0; index < 8; index += 1) { - await flushAsyncTicks(2) - pendingParses.shift()?.() - } - await flushAsyncTicks(4) - - expect(pane.terminal.write).not.toHaveBeenCalledWith('first replay', expect.any(Function)) - expect(pane.terminal.write).toHaveBeenCalledWith('second replay', expect.any(Function)) - expect(manager.rebuildPaneWebgl).toHaveBeenCalledTimes(1) - disposable.dispose() - }) - - it('holds newer live bytes until a later replay frame has fully parsed', async () => { - const { connectPanePty } = await import('./pty-connection') - const { deliverTerminalDataWithDeferredCredit } = - await import('@/lib/pane-manager/terminal-delivery-credit') - enableActiveRuntimeEnvironment() - const transport = createMockTransport('remote:env-1@@terminal-live-order') - const callbacksRef: { - replay: ((data: string) => void) | null - data: ((data: string) => void) | null - } = { replay: null, data: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - callbacksRef.replay = callbacks.onReplayData ?? null - callbacksRef.data = callbacks.onData ?? null - return 'remote:env-1@@terminal-live-order' - }) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(8) - - callbacksRef.replay?.('authoritative replay') - await flushAsyncTicks(8) - expect(writes).toEqual(['\x1b[?2026l\x1b[2J\x1b[3J\x1b[H']) - - const acknowledgeLiveFrame = vi.fn() - deliverTerminalDataWithDeferredCredit(acknowledgeLiveFrame, () => { - callbacksRef.data?.('NEWER-LIVE\r\n') - }) - expect(writes).not.toContain('NEWER-LIVE\r\n') - expect(acknowledgeLiveFrame).not.toHaveBeenCalled() - for (let index = 0; index < 12 && parseCallbacks.length > 0; index += 1) { - parseCallbacks.shift()?.() - await flushAsyncTicks(4) - } - await flushAsyncTicks(8) - - const replayIndex = writes.indexOf('authoritative replay') - const resetIndex = writes.indexOf(replayEpilogue(POST_REPLAY_REATTACH_RESET)) - const liveIndex = writes.indexOf('NEWER-LIVE\r\n') - expect(replayIndex).toBeGreaterThan(0) - expect(resetIndex).toBeGreaterThan(replayIndex) - expect(liveIndex).toBeGreaterThan(resetIndex) - expect(acknowledgeLiveFrame).toHaveBeenCalledOnce() - - callbacksRef.replay?.('stalled replay') - await flushAsyncTicks(8) - const acknowledgeDisposedFrame = vi.fn() - deliverTerminalDataWithDeferredCredit(acknowledgeDisposedFrame, () => { - callbacksRef.data?.('LIVE-BEHIND-STALLED-REPLAY') - }) - expect(acknowledgeDisposedFrame).not.toHaveBeenCalled() - binding.dispose() - expect(acknowledgeDisposedFrame).toHaveBeenCalledOnce() - }) - - it('drops a queued relay replay instead of retagging it for a replacement PTY', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const transport = createMockTransport('remote:env-1@@terminal-old') - const replayCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - replayCallback.current = callbacks.onReplayData ?? null - return 'remote:env-1@@terminal-old' - }) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(8) - - replayCallback.current?.('blocking replay') - await flushAsyncTicks(8) - replayCallback.current?.('stale queued replay') - vi.mocked(transport.getPtyId).mockReturnValue('remote:env-1@@terminal-replacement') - while (parseCallbacks.length > 0) { - parseCallbacks.shift()?.() - await flushAsyncTicks(4) - } - await flushAsyncTicks(12) - - expect(writes).not.toContain('blocking replay') - expect(writes).not.toContain('stale queued replay') - binding.dispose() - }) - - it('requests snapshot recovery for one oversized live frame deferred by replay', async () => { - const { connectPanePty } = await import('./pty-connection') - const { deliverTerminalDataWithDeferredCredit } = - await import('@/lib/pane-manager/terminal-delivery-credit') - const transport = createMockTransport('pty-large-live') - const callbacksRef: { - replay: ((data: string) => void) | null - data: ((data: string) => void) | null - } = { replay: null, data: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - callbacksRef.replay = callbacks.onReplayData ?? null - callbacksRef.data = callbacks.onData ?? null - return 'pty-large-live' - }) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) - const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(8) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue(null) - getMainBufferSnapshot.mockClear() - - callbacksRef.replay?.('authoritative replay') - await flushAsyncTicks(8) - const oversizedLiveFrame = 'L'.repeat(512 * 1024 + 1) - const acknowledgeDroppedFrame = vi.fn() - deliverTerminalDataWithDeferredCredit(acknowledgeDroppedFrame, () => { - callbacksRef.data?.(oversizedLiveFrame) - }) - expect(acknowledgeDroppedFrame).not.toHaveBeenCalled() - while (parseCallbacks.length > 0) { - parseCallbacks.shift()?.() - await flushAsyncTicks(4) - } - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-large-live', { - scrollbackRows: 5000 - }) - expect(writes.some((write) => write.startsWith('L'))).toBe(false) - expect(acknowledgeDroppedFrame).toHaveBeenCalledOnce() - binding.dispose() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-restart-replacement.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-restart-replacement.spec.ts new file mode 100644 index 00000000000..e3b85402139 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-restart-replacement.spec.ts @@ -0,0 +1,185 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + leafIdForPane, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionRestartReplacementTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + /** A pane running an agent, with the live recovery record the app keeps for every agent turn. */ + async function connectWithAgent(opts: { visible: boolean; turnDone?: boolean }) { + const { connectPanePty } = await import('./pty-connection') + const { deliverPtyExitToHandlers } = await import('./pty-exit-delivery') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const deps = createDeps({ isVisibleRef: { current: opts.visible } }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.removeAgentStatus.mockImplementation((key: string) => { + delete mockStoreState.agentStatusByPaneKey[key] + }) + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + prompt: 'fix it', + state: opts.turnDone ? 'done' : 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'live' + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. + const args = [pane, createManager(1), deps] as unknown as Parameters + const binding = connectPanePty(...args) + await flushAsyncTicks() + const onPtyExit = createdTransportOptions[0]?.onPtyExit + if (typeof onPtyExit !== 'function') { + throw new Error('expected the transport exit handler') + } + const exit = async (replacedByRestart: boolean): Promise => { + deliverPtyExitToHandlers({ + ptyId: 'tab-pty', + code: -1, + replacedByRestart, + primary: (code) => onPtyExit('tab-pty', code), + sidecars: [] + }) + await flushAsyncTicks() + } + const connects = (): number => transport.connect.mock.calls.length + const lastCommand = (): string | undefined => + transport.connect.mock.calls.at(-1)?.[0]?.command + return { binding, exit, connects, lastCommand } + } + + it('resumes a restart-replaced hidden pane once, when it is next shown', async () => { + const pane = await connectWithAgent({ visible: false }) + const before = pane.connects() + + await pane.exit(true) + expect(pane.connects()).toBe(before) + + pane.binding.noteVisibilityResume() + await flushAsyncTicks() + expect(pane.connects()).toBe(before + 1) + expect(pane.lastCommand()).toContain('sess-1') + + pane.binding.noteVisibilityResume() + await flushAsyncTicks() + expect(pane.connects()).toBe(before + 1) + }) + + it('resumes a restart-replaced pane at once when it is visible', async () => { + const pane = await connectWithAgent({ visible: true }) + const before = pane.connects() + + await pane.exit(true) + + expect(pane.connects()).toBe(before + 1) + expect(pane.lastCommand()).toContain('sess-1') + }) + + it('spawns nothing for a restart-replaced pane closed before it is shown', async () => { + const pane = await connectWithAgent({ visible: false }) + await pane.exit(true) + const before = pane.connects() + + pane.binding.dispose() + pane.binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(pane.connects()).toBe(before) + }) + + it('spawns nothing when another flow rebound the pane before it is shown', async () => { + const pane = await connectWithAgent({ visible: false }) + await pane.exit(true) + const before = pane.connects() + mockStoreState.terminalLayoutsByTabId = { + ...mockStoreState.terminalLayoutsByTabId, + 'tab-1': { + root: { type: 'leaf', leafId: leafIdForPane(2) }, + activeLeafId: leafIdForPane(2), + expandedLeafId: null, + ptyIdsByLeafId: { [leafIdForPane(2)]: 'pty-replacement' } + } + } + + pane.binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(pane.connects()).toBe(before) + }) + + it('resumes an agent whose turn had finished the same way, once, when shown', async () => { + const pane = await connectWithAgent({ visible: false, turnDone: true }) + const before = pane.connects() + await pane.exit(true) + expect(pane.connects()).toBe(before) + + pane.binding.noteVisibilityResume() + await flushAsyncTicks() + pane.binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(pane.connects()).toBe(before + 1) + expect(pane.lastCommand()).toContain('sess-1') + }) + + it('leaves a pane lost to any other -1 exit without a process', async () => { + const pane = await connectWithAgent({ visible: false }) + const before = pane.connects() + await pane.exit(false) + + pane.binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(pane.connects()).toBe(before) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-restart-replacement.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-restart-replacement.test.ts deleted file mode 100644 index bbcd67e7ec1..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-restart-replacement.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - leafIdForPane, - createMockTransport, - createPane, - createManager -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import type { MockTransport } from './pty-connection-test-pane-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - /** A pane running an agent, with the live recovery record the app keeps for every agent turn. */ - async function connectWithAgent(opts: { visible: boolean; turnDone?: boolean }) { - const { connectPanePty } = await import('./pty-connection') - const { deliverPtyExitToHandlers } = await import('./pty-exit-delivery') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const deps = createDeps({ isVisibleRef: { current: opts.visible } }) - const pane = createPane(2) - const paneKey = `tab-1:${leafIdForPane(2)}` - mockStoreState.removeAgentStatus.mockImplementation((key: string) => { - delete mockStoreState.agentStatusByPaneKey[key] - }) - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'sess-1' }, - prompt: 'fix it', - state: opts.turnDone ? 'done' : 'working', - capturedAt: 1, - updatedAt: 1, - origin: 'live' - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. - const args = [pane, createManager(1), deps] as unknown as Parameters - const binding = connectPanePty(...args) - await flushAsyncTicks() - const onPtyExit = createdTransportOptions[0]?.onPtyExit - if (typeof onPtyExit !== 'function') { - throw new Error('expected the transport exit handler') - } - const exit = async (replacedByRestart: boolean): Promise => { - deliverPtyExitToHandlers({ - ptyId: 'tab-pty', - code: -1, - replacedByRestart, - primary: (code) => onPtyExit('tab-pty', code), - sidecars: [] - }) - await flushAsyncTicks() - } - const connects = (): number => transport.connect.mock.calls.length - const lastCommand = (): string | undefined => transport.connect.mock.calls.at(-1)?.[0]?.command - return { binding, exit, connects, lastCommand } - } - - it('resumes a restart-replaced hidden pane once, when it is next shown', async () => { - const pane = await connectWithAgent({ visible: false }) - const before = pane.connects() - - await pane.exit(true) - expect(pane.connects()).toBe(before) - - pane.binding.noteVisibilityResume() - await flushAsyncTicks() - expect(pane.connects()).toBe(before + 1) - expect(pane.lastCommand()).toContain('sess-1') - - pane.binding.noteVisibilityResume() - await flushAsyncTicks() - expect(pane.connects()).toBe(before + 1) - }) - - it('resumes a restart-replaced pane at once when it is visible', async () => { - const pane = await connectWithAgent({ visible: true }) - const before = pane.connects() - - await pane.exit(true) - - expect(pane.connects()).toBe(before + 1) - expect(pane.lastCommand()).toContain('sess-1') - }) - - it('spawns nothing for a restart-replaced pane closed before it is shown', async () => { - const pane = await connectWithAgent({ visible: false }) - await pane.exit(true) - const before = pane.connects() - - pane.binding.dispose() - pane.binding.noteVisibilityResume() - await flushAsyncTicks() - - expect(pane.connects()).toBe(before) - }) - - it('spawns nothing when another flow rebound the pane before it is shown', async () => { - const pane = await connectWithAgent({ visible: false }) - await pane.exit(true) - const before = pane.connects() - mockStoreState.terminalLayoutsByTabId = { - ...mockStoreState.terminalLayoutsByTabId, - 'tab-1': { - root: { type: 'leaf', leafId: leafIdForPane(2) }, - activeLeafId: leafIdForPane(2), - expandedLeafId: null, - ptyIdsByLeafId: { [leafIdForPane(2)]: 'pty-replacement' } - } - } - - pane.binding.noteVisibilityResume() - await flushAsyncTicks() - - expect(pane.connects()).toBe(before) - }) - - it('resumes an agent whose turn had finished the same way, once, when shown', async () => { - const pane = await connectWithAgent({ visible: false, turnDone: true }) - const before = pane.connects() - await pane.exit(true) - expect(pane.connects()).toBe(before) - - pane.binding.noteVisibilityResume() - await flushAsyncTicks() - pane.binding.noteVisibilityResume() - await flushAsyncTicks() - - expect(pane.connects()).toBe(before + 1) - expect(pane.lastCommand()).toContain('sess-1') - }) - - it('leaves a pane lost to any other -1 exit without a process', async () => { - const pane = await connectWithAgent({ visible: false }) - const before = pane.connects() - await pane.exit(false) - - pane.binding.noteVisibilityResume() - await flushAsyncTicks() - - expect(pane.connects()).toBe(before) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-restore-and-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-restore-and-resume.test.ts new file mode 100644 index 00000000000..d2e0095ba8e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-restore-and-resume.test.ts @@ -0,0 +1,24 @@ +import { describe } from 'vitest' +import { registerPtyConnectionAgentSessionResumeTests } from './pty-connection-agent-session-resume.spec' +import { registerPtyConnectionColdRestoreAgentResumeTests } from './pty-connection-cold-restore-agent-resume.spec' +import { registerPtyConnectionColdRestoreRepaintTests } from './pty-connection-cold-restore-repaint.spec' +import { registerPtyConnectionColdRestoreResumeCommandTests } from './pty-connection-cold-restore-resume-command.spec' +import { registerPtyConnectionHibernationWakeTests } from './pty-connection-hibernation-wake.spec' +import { registerPtyConnectionRestartReplacementTests } from './pty-connection-restart-replacement.spec' +import { registerPtyConnectionSessionLivenessTests } from './pty-connection-session-liveness.spec' +import { registerPtyConnectionSleepingResumeBannerTests } from './pty-connection-sleeping-resume-banner.spec' + +describe('pty-connection-agent-session-resume', registerPtyConnectionAgentSessionResumeTests) +describe( + 'pty-connection-cold-restore-agent-resume', + registerPtyConnectionColdRestoreAgentResumeTests +) +describe('pty-connection-cold-restore-repaint', registerPtyConnectionColdRestoreRepaintTests) +describe( + 'pty-connection-cold-restore-resume-command', + registerPtyConnectionColdRestoreResumeCommandTests +) +describe('pty-connection-hibernation-wake', registerPtyConnectionHibernationWakeTests) +describe('pty-connection-restart-replacement', registerPtyConnectionRestartReplacementTests) +describe('pty-connection-session-liveness', registerPtyConnectionSessionLivenessTests) +describe('pty-connection-sleeping-resume-banner', registerPtyConnectionSleepingResumeBannerTests) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.spec.ts new file mode 100644 index 00000000000..abbe10d13be --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.spec.ts @@ -0,0 +1,397 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + LEAF_2, + createMockTransport, + createPane, + captureCallbackTerminalWrites, + createManager +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionRuntimeOwnerSpawnRoutingTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('fails closed when the same SSH worktree id is projected by two HUBs', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { createIpcPtyTransport } = await import('./pty-transport') + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + worktreesByRepo: { + repo1: [ + { + id: 'wt-1', + repoId: 'repo1', + path: '/srv/same-worktree', + hostId: 'ssh:same-private-target', + runtimeOwnerEnvironmentId: 'hub-a' + }, + { + id: 'wt-1', + repoId: 'repo1', + path: '/srv/same-worktree', + hostId: 'ssh:same-private-target', + runtimeOwnerEnvironmentId: 'hub-b' + } + ] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'hub-a' + } + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ restoredPtyIdByLeafId: { [LEAF_1]: null } }) as never + ) + await flushAsyncTicks() + + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + expect(createIpcPtyTransport).not.toHaveBeenCalled() + expect(window.api.ssh.connect).not.toHaveBeenCalled() + expect(window.api.ssh.needsPassphrasePrompt).not.toHaveBeenCalled() + }) + + it('fails a missing paired-client owner closed instead of creating a local PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { createIpcPtyTransport } = await import('./pty-transport') + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + worktreesByRepo: { + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/srv/stale-worktree' }] + }, + runtimeEnvironments: [{ id: 'hub-a' }, { id: 'hub-b' }], + runtimeEnvironmentCatalogHydrated: true, + settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'hub-a' } + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ restoredPtyIdByLeafId: { [LEAF_1]: null } }) as never + ) + await flushAsyncTicks() + + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + expect(createIpcPtyTransport).not.toHaveBeenCalled() + expect(window.api.ssh.connect).not.toHaveBeenCalled() + }) + + it('spawns fresh PTYs through the worktree owner runtime when focus differs', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport('remote:owner-runtime@@terminal-1') + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null }] + }, + repos: [ + { + id: 'repo1', + connectionId: null, + displayName: 'orca', + executionHostId: 'runtime:owner-runtime' + } + ], + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'focused-runtime' + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps({ + startup: { + command: "codex '--profile' 'recipe'", + launchAgent: 'codex', + launchConfig: { agentArgs: '--profile recipe', agentEnv: {} }, + agentArgsOverride: '--profile recipe' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith( + 'owner-runtime', + expect.any(Object) + ) + expect(createdTransportOptions[0]?.cwdFallback).toBeUndefined() + expect(createdTransportOptions[0]?.agentArgsOverride).toBe('--profile recipe') + expect(transport.connect).toHaveBeenCalled() + }) + + it('spawns fresh PTYs locally for explicitly local worktrees while a runtime is focused', async () => { + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createMockTransport('pty-local-1') + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null }] + }, + repos: [ + { + id: 'repo1', + connectionId: null, + displayName: 'orca', + executionHostId: 'local' + } + ], + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'focused-runtime' + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + expect(createIpcPtyTransport).toHaveBeenCalled() + expect(createdTransportOptions[0]?.cwdFallback).toBe('worktree') + expect(transport.connect).toHaveBeenCalled() + }) + + it('prints a terminal notice when the startup cwd fell back to the workspace root', async () => { + const { connectPanePty, STARTUP_CWD_FALLBACK_NOTICE } = await import('./pty-connection') + const transport = createMockTransport('pty-fallback') + transport.connect.mockResolvedValueOnce({ + id: 'pty-fallback', + startupCwdFallback: { kind: 'worktree', cwd: '/tmp/wt-1' } + }) + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null }] + } + } as StoreState + + const pane = createPane(2) + const { writes } = captureCallbackTerminalWrites(pane) + + connectPanePty(pane as never, createManager(2) as never, createDeps() as never) + await flushAsyncTicks() + + expect(writes).toContain(STARTUP_CWD_FALLBACK_NOTICE) + }) + + it('attaches restored remote PTYs for later split panes instead of spawning host tabs', async () => { + const { connectPanePty } = await import('./pty-connection') + const existingTransport = createMockTransport('remote:env-1@@terminal-1') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'remote:env-1@@terminal-1' }] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-1' + } + } as StoreState + + const paneTransports = new Map([[1, existingTransport]]) + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { + [LEAF_2]: 'remote:env-1@@terminal-2' + }, + paneTransportsRef: { current: paneTransports } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + expect(transport.connect).not.toHaveBeenCalled() + expect(transport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: 'remote:env-1@@terminal-2' }) + ) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-2') + }) + + it('persists a restarted pane PTY id and uses it on the next remount', async () => { + const { connectPanePty } = await import('./pty-connection') + + const restartedTransport = createMockTransport() + let spawnedPtyId: string | null = null + restartedTransport.connect.mockImplementation(async () => { + spawnedPtyId = 'pty-restarted' + const opts = createdTransportOptions[0] + ;(opts.onPtySpawn as (ptyId: string) => void)('pty-restarted') + return 'pty-restarted' + }) + transportFactoryQueue.push(restartedTransport) + + const restartPane = createPane(1) + const restartManager = createManager(1) + const restartDeps = createDeps({ + paneTransportsRef: { current: new Map([[99, createMockTransport('another-pane-pty')]]) } + }) + + connectPanePty(restartPane as never, restartManager as never, restartDeps as never) + await flushAsyncTicks() + + expect(spawnedPtyId).toBe('pty-restarted') + expect(restartDeps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-restarted') + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-restarted' }] + }, + settings: { + ...mockStoreState.settings + } + } + + const remountTransport = createMockTransport() + transportFactoryQueue.push(remountTransport) + const remountPane = createPane(1) + const remountManager = createManager(1) + const remountDeps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'pty-restarted' } + }) + + connectPanePty(remountPane as never, remountManager as never, remountDeps as never) + + expect(remountTransport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'pty-restarted' }) + ) + expect(remountTransport.attach).not.toHaveBeenCalled() + await Promise.resolve() + expect(remountDeps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-restarted') + }) + + // Why: BEL (0x07) is the attention signal — onBell raises the bell event + an OS notification. + it('wires onBell to the bell attention event and OS notification', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + if (!bellHandler) { + throw new Error('Expected onBell to be registered') + } + + bellHandler() + + expect(deps.markTerminalBell).toHaveBeenCalledTimes(1) + expect(deps.markTerminalBell).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId: 'tab-1', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + expect(deps.dispatchNotification).not.toHaveBeenCalled() + vi.advanceTimersByTime(250) + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'terminal-bell', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + ) + }) + + it('hands the bell to the attention module with its pane even when pane attention is off', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: false + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + if (!bellHandler) { + throw new Error('Expected onBell to be registered') + } + + bellHandler() + + // Why: the experimental pane gate lives in the workspace-attention module, not the PTY binding. + expect(deps.markTerminalBell).toHaveBeenCalledTimes(1) + expect(deps.markTerminalBell).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId: 'tab-1', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + }) + + // Kill switch on (default): no byte parsers; pane policy is the PTY's single fact consumer (terminal-side-effect-authority.md). + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts deleted file mode 100644 index 889065a7b75..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - LEAF_1, - LEAF_2, - createMockTransport, - createPane, - captureCallbackTerminalWrites, - createManager -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import type { MockTransport } from './pty-connection-test-pane-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('fails closed when the same SSH worktree id is projected by two HUBs', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const { createIpcPtyTransport } = await import('./pty-transport') - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - worktreesByRepo: { - repo1: [ - { - id: 'wt-1', - repoId: 'repo1', - path: '/srv/same-worktree', - hostId: 'ssh:same-private-target', - runtimeOwnerEnvironmentId: 'hub-a' - }, - { - id: 'wt-1', - repoId: 'repo1', - path: '/srv/same-worktree', - hostId: 'ssh:same-private-target', - runtimeOwnerEnvironmentId: 'hub-b' - } - ] - }, - settings: { - ...mockStoreState.settings, - activeRuntimeEnvironmentId: 'hub-a' - } - } as StoreState - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ restoredPtyIdByLeafId: { [LEAF_1]: null } }) as never - ) - await flushAsyncTicks() - - expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() - expect(createIpcPtyTransport).not.toHaveBeenCalled() - expect(window.api.ssh.connect).not.toHaveBeenCalled() - expect(window.api.ssh.needsPassphrasePrompt).not.toHaveBeenCalled() - }) - - it('fails a missing paired-client owner closed instead of creating a local PTY', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const { createIpcPtyTransport } = await import('./pty-transport') - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - worktreesByRepo: { - repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/srv/stale-worktree' }] - }, - runtimeEnvironments: [{ id: 'hub-a' }, { id: 'hub-b' }], - runtimeEnvironmentCatalogHydrated: true, - settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'hub-a' } - } as StoreState - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ restoredPtyIdByLeafId: { [LEAF_1]: null } }) as never - ) - await flushAsyncTicks() - - expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() - expect(createIpcPtyTransport).not.toHaveBeenCalled() - expect(window.api.ssh.connect).not.toHaveBeenCalled() - }) - - it('spawns fresh PTYs through the worktree owner runtime when focus differs', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const transport = createMockTransport('remote:owner-runtime@@terminal-1') - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: null }] - }, - repos: [ - { - id: 'repo1', - connectionId: null, - displayName: 'orca', - executionHostId: 'runtime:owner-runtime' - } - ], - settings: { - ...mockStoreState.settings, - activeRuntimeEnvironmentId: 'focused-runtime' - } - } as StoreState - - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - startup: { - command: "codex '--profile' 'recipe'", - launchAgent: 'codex', - launchConfig: { agentArgs: '--profile recipe', agentEnv: {} }, - agentArgsOverride: '--profile recipe' - } - }) - - connectPanePty(pane as never, manager as never, deps as never) - - expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith( - 'owner-runtime', - expect.any(Object) - ) - expect(createdTransportOptions[0]?.cwdFallback).toBeUndefined() - expect(createdTransportOptions[0]?.agentArgsOverride).toBe('--profile recipe') - expect(transport.connect).toHaveBeenCalled() - }) - - it('spawns fresh PTYs locally for explicitly local worktrees while a runtime is focused', async () => { - const { connectPanePty } = await import('./pty-connection') - const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') - const { createIpcPtyTransport } = await import('./pty-transport') - const transport = createMockTransport('pty-local-1') - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: null }] - }, - repos: [ - { - id: 'repo1', - connectionId: null, - displayName: 'orca', - executionHostId: 'local' - } - ], - settings: { - ...mockStoreState.settings, - activeRuntimeEnvironmentId: 'focused-runtime' - } - } as StoreState - - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() - expect(createIpcPtyTransport).toHaveBeenCalled() - expect(createdTransportOptions[0]?.cwdFallback).toBe('worktree') - expect(transport.connect).toHaveBeenCalled() - }) - - it('prints a terminal notice when the startup cwd fell back to the workspace root', async () => { - const { connectPanePty, STARTUP_CWD_FALLBACK_NOTICE } = await import('./pty-connection') - const transport = createMockTransport('pty-fallback') - transport.connect.mockResolvedValueOnce({ - id: 'pty-fallback', - startupCwdFallback: { kind: 'worktree', cwd: '/tmp/wt-1' } - }) - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: null }] - } - } as StoreState - - const pane = createPane(2) - const { writes } = captureCallbackTerminalWrites(pane) - - connectPanePty(pane as never, createManager(2) as never, createDeps() as never) - await flushAsyncTicks() - - expect(writes).toContain(STARTUP_CWD_FALLBACK_NOTICE) - }) - - it('attaches restored remote PTYs for later split panes instead of spawning host tabs', async () => { - const { connectPanePty } = await import('./pty-connection') - const existingTransport = createMockTransport('remote:env-1@@terminal-1') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'remote:env-1@@terminal-1' }] - }, - settings: { - ...mockStoreState.settings, - activeRuntimeEnvironmentId: 'env-1' - } - } as StoreState - - const paneTransports = new Map([[1, existingTransport]]) - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { - [LEAF_2]: 'remote:env-1@@terminal-2' - }, - paneTransportsRef: { current: paneTransports } - }) - - connectPanePty(pane as never, manager as never, deps as never) - - expect(transport.connect).not.toHaveBeenCalled() - expect(transport.attach).toHaveBeenCalledWith( - expect.objectContaining({ existingPtyId: 'remote:env-1@@terminal-2' }) - ) - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-2') - }) - - it('persists a restarted pane PTY id and uses it on the next remount', async () => { - const { connectPanePty } = await import('./pty-connection') - - const restartedTransport = createMockTransport() - let spawnedPtyId: string | null = null - restartedTransport.connect.mockImplementation(async () => { - spawnedPtyId = 'pty-restarted' - const opts = createdTransportOptions[0] - ;(opts.onPtySpawn as (ptyId: string) => void)('pty-restarted') - return 'pty-restarted' - }) - transportFactoryQueue.push(restartedTransport) - - const restartPane = createPane(1) - const restartManager = createManager(1) - const restartDeps = createDeps({ - paneTransportsRef: { current: new Map([[99, createMockTransport('another-pane-pty')]]) } - }) - - connectPanePty(restartPane as never, restartManager as never, restartDeps as never) - await flushAsyncTicks() - - expect(spawnedPtyId).toBe('pty-restarted') - expect(restartDeps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-restarted') - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'pty-restarted' }] - }, - settings: { - ...mockStoreState.settings - } - } - - const remountTransport = createMockTransport() - transportFactoryQueue.push(remountTransport) - const remountPane = createPane(1) - const remountManager = createManager(1) - const remountDeps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'pty-restarted' } - }) - - connectPanePty(remountPane as never, remountManager as never, remountDeps as never) - - expect(remountTransport.connect).toHaveBeenCalledWith( - expect.objectContaining({ sessionId: 'pty-restarted' }) - ) - expect(remountTransport.attach).not.toHaveBeenCalled() - await Promise.resolve() - expect(remountDeps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-restarted') - }) - - // Why: BEL (0x07) is the attention signal — onBell raises the bell event + an OS notification. - it('wires onBell to the bell attention event and OS notification', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined - if (!bellHandler) { - throw new Error('Expected onBell to be registered') - } - - bellHandler() - - expect(deps.markTerminalBell).toHaveBeenCalledTimes(1) - expect(deps.markTerminalBell).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - tabId: 'tab-1', - paneKey: makePaneKey('tab-1', LEAF_1) - }) - expect(deps.dispatchNotification).not.toHaveBeenCalled() - vi.advanceTimersByTime(250) - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'terminal-bell', - paneKey: makePaneKey('tab-1', LEAF_1) - }) - ) - }) - - it('hands the bell to the attention module with its pane even when pane attention is off', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState.settings = { - ...mockStoreState.settings, - experimentalTerminalAttention: false - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined - if (!bellHandler) { - throw new Error('Expected onBell to be registered') - } - - bellHandler() - - // Why: the experimental pane gate lives in the workspace-attention module, not the PTY binding. - expect(deps.markTerminalBell).toHaveBeenCalledTimes(1) - expect(deps.markTerminalBell).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - tabId: 'tab-1', - paneKey: makePaneKey('tab-1', LEAF_1) - }) - }) - - // Kill switch on (default): no byte parsers; pane policy is the PTY's single fact consumer (terminal-side-effect-authority.md). -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.spec.ts new file mode 100644 index 00000000000..c58c559bd18 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.spec.ts @@ -0,0 +1,562 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { sendTerminalInputThroughPane } from './pty-connection-test-dom' +import { + LEAF_2, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { + createInitialStoreState, + buildActiveRuntimeEnvironmentState +} from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionSessionLivenessTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + function seedUnverifiableRestoredPane() { + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'unverifiable-pty' }] }, + ptyIdsByTabId: { 'tab-1': ['unverifiable-pty'] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'unverifiable-pty' } + } + } + } as StoreState + return createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: 'unverifiable-pty' } + }) + } + + // Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. + function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { + mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + describe('reconcileIfSessionMissing', () => { + it('closes a split pane when targeted liveness says its local session is missing', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-pane-2') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-pane-2' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const hasPty = vi.fn(async () => false) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + capturedDataCallback.current?.('shell prompt') + + binding.reconcileIfSessionMissing(hasPty) + await flushAsyncTicks() + + expect(hasPty).toHaveBeenCalledWith('pty-pane-2') + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + + it('does not close when targeted liveness is live or unknown', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + + binding.reconcileIfSessionMissing(vi.fn(async () => true)) + binding.reconcileIfSessionMissing(vi.fn(async () => null)) + await flushAsyncTicks() + + expect(manager.closePane).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + }) + + it('does not apply a stale targeted liveness result after reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + let resolveHasPty: (value: boolean) => void = () => { + throw new Error('hasPty promise resolver was not initialized') + } + const hasPty = vi.fn( + () => + new Promise((resolve) => { + resolveHasPty = resolve + }) + ) + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + binding.reconcileIfSessionMissing(hasPty) + transport.getPtyId.mockReturnValue('pty-pane-2-reattached') + resolveHasPty(false) + await flushAsyncTicks() + + expect(manager.closePane).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + }) + + it('does NOT tear down a newborn pane when liveness was requested before it bound', async () => { + // Why (regression): a liveness request made before the spawn bound cannot prove the fresh ptyId dead (boundAt wiring). + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-pane-2') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-pane-2' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + // Why: clear the freshly-split early-return guard so the only remaining protection is the freshness guard under test. + capturedDataCallback.current?.('shell prompt') + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + expect(onPtySpawn).toBeTypeOf('function') + + // Record boundAt via the spawn chokepoint; bracket it with a real timestamp. + const beforeSpawn = performance.now() + onPtySpawn?.('pty-pane-2') + + // requestedAt < boundAt: a stale liveness answer can't prove the fresh pane dead. + binding.reconcileIfSessionMissing(async () => false, beforeSpawn - 1) + await flushAsyncTicks() + + expect(manager.closePane).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + }) + + it('tears down the pane when liveness was requested after it bound', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-pane-2') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-pane-2' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + // Why: clear the freshly-split early-return guard so onExit reaches close. + capturedDataCallback.current?.('shell prompt') + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + expect(onPtySpawn).toBeTypeOf('function') + + onPtySpawn?.('pty-pane-2') + const afterSpawn = performance.now() + + // requestedAt > boundAt: the request postdates the bind, so absence is real. + binding.reconcileIfSessionMissing(async () => false, afterSpawn + 1) + await flushAsyncTicks() + + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + + it('routes the last pane through onPtyExitRef when its session is dead', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-1') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + + const binding = connectPanePty(createPane(1) as never, manager as never, deps as never) + + // The last pane reattaches to the tab's persisted ptyId ('tab-pty'); let that bind first. + await flushAsyncTicks() + binding.reconcileIfSessionMissing(async () => false) + await flushAsyncTicks() + + expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty', 0) + expect(manager.closePane).not.toHaveBeenCalled() + }) + + it('is a no-op for remote: web-runtime ids', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment('env-1') + const transport = createMockTransport('remote:env-1:pane-2') + transport.getConnectionId.mockReturnValue(null) + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + + binding.reconcileIfSessionMissing(async () => false) + await flushAsyncTicks() + + expect(manager.closePane).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + }) + + it('is a no-op for SSH/non-local ids (non-null connectionId)', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transport.getConnectionId.mockReturnValue('ssh-target-1') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + + binding.reconcileIfSessionMissing(async () => false) + await flushAsyncTicks() + + expect(manager.closePane).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + }) + + it('respects suppression: a suppressed dead session keeps the pane mounted', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + consumeSuppressedPtyExit: vi.fn(() => true), + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + + binding.reconcileIfSessionMissing(async () => false) + await flushAsyncTicks() + + expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2') + expect(manager.closePane).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + }) + + it('still closes the replacement PTY after a suppressed restart rebinds the pane', async () => { + // Why (regression): the exit guard is scoped to the exiting ptyId, not a one-shot boolean, so a rebound replacement's later real exit still tears down. + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-pane-2') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-pane-2' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(2) + // First exit is suppressed (intentional restart); later exits are real. + const consumeSuppressedPtyExit = vi + .fn<(ptyId: string) => boolean>() + .mockImplementationOnce(() => true) + .mockImplementation(() => false) + const deps = createDeps({ + restoredLeafId: LEAF_2, + consumeSuppressedPtyExit, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + capturedDataCallback.current?.('shell prompt') + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + + // Suppressed exit of the original PTY: pane stays mounted. + onPtyExit?.('pty-pane-2') + expect(manager.closePane).not.toHaveBeenCalled() + + // Restart rebinds the pane to a new live PTY; its later real exit must not be ignored by a stale guard. + transport.getPtyId.mockReturnValue('pty-pane-2-restarted') + onPtyExit?.('pty-pane-2-restarted') + + expect(manager.closePane).toHaveBeenCalledTimes(1) + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + + it('closes a split pane exactly once across reconcile + a racing real exit', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-pane-2') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-pane-2' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + capturedDataCallback.current?.('shell prompt') + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + expect(onPtyExit).toBeTypeOf('function') + + binding.reconcileIfSessionMissing(async () => false) + await flushAsyncTicks() + // A racing real/synthetic pty:exit for the SAME id must not close twice. + onPtyExit?.('pty-pane-2') + + expect(manager.closePane).toHaveBeenCalledTimes(1) + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + + it('closes a genuinely-dead non-suppressed pane once (not misread as suppressed)', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-pane-2') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-pane-2' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(2) + // consumeSuppressedPtyExit always returns false for this genuinely-dead id. + const deps = createDeps({ + restoredLeafId: LEAF_2, + consumeSuppressedPtyExit: vi.fn(() => false), + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + capturedDataCallback.current?.('shell prompt') + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + + binding.reconcileIfSessionMissing(async () => false) + await flushAsyncTicks() + onPtyExit?.('pty-pane-2') + + // Observable outcome: single close, pane was treated as dead (closed). + expect(manager.closePane).toHaveBeenCalledTimes(1) + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + }) + + it('preserves an owner-unverified restored pane after an empty reattach result', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async (opts: { callbacks?: ConnectCallbacks }) => { + opts.callbacks?.onError?.('terminal_pane_owner_unverified') + return undefined + }) + transportFactoryQueue.push(transport) + const deps = seedUnverifiableRestoredPane() + + connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(2, 'terminal_pane_owner_unverified') + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(mockStoreState.tabsByWorktree['wt-1'][0]?.ptyId).toBe('unverifiable-pty') + expect(mockStoreState.ptyIdsByTabId?.['tab-1']).toEqual(['unverifiable-pty']) + expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId).toEqual({ + [LEAF_2]: 'unverifiable-pty' + }) + expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(expect.any(String), 1) + }) + + it('preserves an owner-unverified restored pane after a rejected reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockRejectedValueOnce(new Error('terminal_pane_owner_unverified')) + transportFactoryQueue.push(transport) + const deps = seedUnverifiableRestoredPane() + + connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(2, 'terminal_pane_owner_unverified') + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId).toEqual({ + [LEAF_2]: 'unverifiable-pty' + }) + expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(expect.any(String), 1) + }) + + describe('terminal input liveness IPC gating (perf)', () => { + // Why (perf regression guard): listSessions() is a renderer→main→daemon round-trip; terminal input must never trigger it. + async function connectActivePaneWithInput(): Promise<{ + binding: { noteVisibilityResume: () => void } + transport: MockTransport + typeKeystroke: (data?: string) => void + }> { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const pane = createPane(2) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The real PanePtyBinding exposes noteVisibilityResume; this fixture narrows it to that method. + const binding = connectPanePty( + pane as never, + manager as never, + deps as never + ) as unknown as { + noteVisibilityResume: () => void + } + return { + binding, + transport, + // Drives the real xterm onData (terminal input) handler. + typeKeystroke: (data = 'a') => sendTerminalInputThroughPane(pane, data) + } + } + + it('does not fire listSessions for first input on a fresh mount', async () => { + const listSessions = vi.mocked(window.api.pty.listSessions) + listSessions.mockClear() + const { typeKeystroke } = await connectActivePaneWithInput() + + for (let i = 0; i < 25; i++) { + typeKeystroke('x') + } + + expect(listSessions).not.toHaveBeenCalled() + }) + + it('does not fire listSessions for input after a visibility resume', async () => { + const listSessions = vi.mocked(window.api.pty.listSessions) + listSessions.mockClear() + const { binding, typeKeystroke } = await connectActivePaneWithInput() + + binding.noteVisibilityResume() + typeKeystroke('a') + typeKeystroke('b') + + expect(listSessions).not.toHaveBeenCalled() + }) + + it('sends the first post-resume input without starting the liveness re-check', async () => { + const listSessions = vi.mocked(window.api.pty.listSessions) + listSessions.mockClear() + const calls: string[] = [] + listSessions.mockImplementation(() => { + calls.push('listSessions') + return Promise.resolve([]) + }) + const { binding, transport, typeKeystroke } = await connectActivePaneWithInput() + transport.sendInput.mockImplementation(() => { + calls.push('sendInput') + return true + }) + + binding.noteVisibilityResume() + typeKeystroke('a') + + expect(calls).toEqual(['sendInput']) + }) + + it('does not re-arm input-driven listSessions across repeated visibility resumes', async () => { + const listSessions = vi.mocked(window.api.pty.listSessions) + listSessions.mockClear() + const { binding, typeKeystroke } = await connectActivePaneWithInput() + + binding.noteVisibilityResume() + typeKeystroke('a') + typeKeystroke('b') + expect(listSessions).not.toHaveBeenCalled() + + binding.noteVisibilityResume() + typeKeystroke('c') + typeKeystroke('d') + expect(listSessions).not.toHaveBeenCalled() + }) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.test.ts deleted file mode 100644 index 8c7f726565d..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.test.ts +++ /dev/null @@ -1,648 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { sendTerminalInputThroughPane } from './pty-connection-test-dom' -import { - LEAF_2, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { - createInitialStoreState, - buildActiveRuntimeEnvironmentState -} from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -function seedUnverifiableRestoredPane() { - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'unverifiable-pty' }] }, - ptyIdsByTabId: { 'tab-1': ['unverifiable-pty'] }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_2 }, - activeLeafId: LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_2]: 'unverifiable-pty' } - } - } - } as StoreState - return createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { [LEAF_2]: 'unverifiable-pty' } - }) -} - -// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. -function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { - mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - describe('reconcileIfSessionMissing', () => { - it('closes a split pane when targeted liveness says its local session is missing', async () => { - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-pane-2') - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-pane-2' - } - ) - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - const hasPty = vi.fn(async () => false) - - const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) - capturedDataCallback.current?.('shell prompt') - - binding.reconcileIfSessionMissing(hasPty) - await flushAsyncTicks() - - expect(hasPty).toHaveBeenCalledWith('pty-pane-2') - expect(manager.closePane).toHaveBeenCalledWith(2) - }) - - it('does not close when targeted liveness is live or unknown', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) - - binding.reconcileIfSessionMissing(vi.fn(async () => true)) - binding.reconcileIfSessionMissing(vi.fn(async () => null)) - await flushAsyncTicks() - - expect(manager.closePane).not.toHaveBeenCalled() - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - }) - - it('does not apply a stale targeted liveness result after reattach', async () => { - const { connectPanePty } = await import('./pty-connection') - let resolveHasPty: (value: boolean) => void = () => { - throw new Error('hasPty promise resolver was not initialized') - } - const hasPty = vi.fn( - () => - new Promise((resolve) => { - resolveHasPty = resolve - }) - ) - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) - binding.reconcileIfSessionMissing(hasPty) - transport.getPtyId.mockReturnValue('pty-pane-2-reattached') - resolveHasPty(false) - await flushAsyncTicks() - - expect(manager.closePane).not.toHaveBeenCalled() - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - }) - - it('does NOT tear down a newborn pane when liveness was requested before it bound', async () => { - // Why (regression): a liveness request made before the spawn bound cannot prove the fresh ptyId dead (boundAt wiring). - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-pane-2') - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-pane-2' - } - ) - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) - // Why: clear the freshly-split early-return guard so the only remaining protection is the freshness guard under test. - capturedDataCallback.current?.('shell prompt') - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - expect(onPtySpawn).toBeTypeOf('function') - - // Record boundAt via the spawn chokepoint; bracket it with a real timestamp. - const beforeSpawn = performance.now() - onPtySpawn?.('pty-pane-2') - - // requestedAt < boundAt: a stale liveness answer can't prove the fresh pane dead. - binding.reconcileIfSessionMissing(async () => false, beforeSpawn - 1) - await flushAsyncTicks() - - expect(manager.closePane).not.toHaveBeenCalled() - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - }) - - it('tears down the pane when liveness was requested after it bound', async () => { - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-pane-2') - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-pane-2' - } - ) - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) - // Why: clear the freshly-split early-return guard so onExit reaches close. - capturedDataCallback.current?.('shell prompt') - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - expect(onPtySpawn).toBeTypeOf('function') - - onPtySpawn?.('pty-pane-2') - const afterSpawn = performance.now() - - // requestedAt > boundAt: the request postdates the bind, so absence is real. - binding.reconcileIfSessionMissing(async () => false, afterSpawn + 1) - await flushAsyncTicks() - - expect(manager.closePane).toHaveBeenCalledWith(2) - }) - - it('routes the last pane through onPtyExitRef when its session is dead', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-1') - transportFactoryQueue.push(transport) - const manager = createManager(1) - const deps = createDeps() - - const binding = connectPanePty(createPane(1) as never, manager as never, deps as never) - - // The last pane reattaches to the tab's persisted ptyId ('tab-pty'); let that bind first. - await flushAsyncTicks() - binding.reconcileIfSessionMissing(async () => false) - await flushAsyncTicks() - - expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty', 0) - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('is a no-op for remote: web-runtime ids', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment('env-1') - const transport = createMockTransport('remote:env-1:pane-2') - transport.getConnectionId.mockReturnValue(null) - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) - - binding.reconcileIfSessionMissing(async () => false) - await flushAsyncTicks() - - expect(manager.closePane).not.toHaveBeenCalled() - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - }) - - it('is a no-op for SSH/non-local ids (non-null connectionId)', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transport.getConnectionId.mockReturnValue('ssh-target-1') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) - - binding.reconcileIfSessionMissing(async () => false) - await flushAsyncTicks() - - expect(manager.closePane).not.toHaveBeenCalled() - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - }) - - it('respects suppression: a suppressed dead session keeps the pane mounted', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - consumeSuppressedPtyExit: vi.fn(() => true), - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) - - binding.reconcileIfSessionMissing(async () => false) - await flushAsyncTicks() - - expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2') - expect(manager.closePane).not.toHaveBeenCalled() - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - }) - - it('still closes the replacement PTY after a suppressed restart rebinds the pane', async () => { - // Why (regression): the exit guard is scoped to the exiting ptyId, not a one-shot boolean, so a rebound replacement's later real exit still tears down. - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-pane-2') - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-pane-2' - } - ) - transportFactoryQueue.push(transport) - const manager = createManager(2) - // First exit is suppressed (intentional restart); later exits are real. - const consumeSuppressedPtyExit = vi - .fn<(ptyId: string) => boolean>() - .mockImplementationOnce(() => true) - .mockImplementation(() => false) - const deps = createDeps({ - restoredLeafId: LEAF_2, - consumeSuppressedPtyExit, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - connectPanePty(createPane(2) as never, manager as never, deps as never) - capturedDataCallback.current?.('shell prompt') - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string) => void) - | undefined - - // Suppressed exit of the original PTY: pane stays mounted. - onPtyExit?.('pty-pane-2') - expect(manager.closePane).not.toHaveBeenCalled() - - // Restart rebinds the pane to a new live PTY; its later real exit must not be ignored by a stale guard. - transport.getPtyId.mockReturnValue('pty-pane-2-restarted') - onPtyExit?.('pty-pane-2-restarted') - - expect(manager.closePane).toHaveBeenCalledTimes(1) - expect(manager.closePane).toHaveBeenCalledWith(2) - }) - - it('closes a split pane exactly once across reconcile + a racing real exit', async () => { - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-pane-2') - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-pane-2' - } - ) - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) - capturedDataCallback.current?.('shell prompt') - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string) => void) - | undefined - expect(onPtyExit).toBeTypeOf('function') - - binding.reconcileIfSessionMissing(async () => false) - await flushAsyncTicks() - // A racing real/synthetic pty:exit for the SAME id must not close twice. - onPtyExit?.('pty-pane-2') - - expect(manager.closePane).toHaveBeenCalledTimes(1) - expect(manager.closePane).toHaveBeenCalledWith(2) - }) - - it('closes a genuinely-dead non-suppressed pane once (not misread as suppressed)', async () => { - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-pane-2') - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-pane-2' - } - ) - transportFactoryQueue.push(transport) - const manager = createManager(2) - // consumeSuppressedPtyExit always returns false for this genuinely-dead id. - const deps = createDeps({ - restoredLeafId: LEAF_2, - consumeSuppressedPtyExit: vi.fn(() => false), - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - - const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) - capturedDataCallback.current?.('shell prompt') - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string) => void) - | undefined - - binding.reconcileIfSessionMissing(async () => false) - await flushAsyncTicks() - onPtyExit?.('pty-pane-2') - - // Observable outcome: single close, pane was treated as dead (closed). - expect(manager.closePane).toHaveBeenCalledTimes(1) - expect(manager.closePane).toHaveBeenCalledWith(2) - }) - }) - - it('preserves an owner-unverified restored pane after an empty reattach result', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transport.connect.mockImplementation(async (opts: { callbacks?: ConnectCallbacks }) => { - opts.callbacks?.onError?.('terminal_pane_owner_unverified') - return undefined - }) - transportFactoryQueue.push(transport) - const deps = seedUnverifiableRestoredPane() - - connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(2, 'terminal_pane_owner_unverified') - expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() - expect(deps.clearTabPtyId).not.toHaveBeenCalled() - expect(mockStoreState.tabsByWorktree['wt-1'][0]?.ptyId).toBe('unverifiable-pty') - expect(mockStoreState.ptyIdsByTabId?.['tab-1']).toEqual(['unverifiable-pty']) - expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId).toEqual({ - [LEAF_2]: 'unverifiable-pty' - }) - expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(expect.any(String), 1) - }) - - it('preserves an owner-unverified restored pane after a rejected reattach', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transport.connect.mockRejectedValueOnce(new Error('terminal_pane_owner_unverified')) - transportFactoryQueue.push(transport) - const deps = seedUnverifiableRestoredPane() - - connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) - await flushAsyncTicks() - - expect(transport.connect).toHaveBeenCalledTimes(1) - expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(2, 'terminal_pane_owner_unverified') - expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() - expect(deps.clearTabPtyId).not.toHaveBeenCalled() - expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId).toEqual({ - [LEAF_2]: 'unverifiable-pty' - }) - expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(expect.any(String), 1) - }) - - describe('terminal input liveness IPC gating (perf)', () => { - // Why (perf regression guard): listSessions() is a renderer→main→daemon round-trip; terminal input must never trigger it. - async function connectActivePaneWithInput(): Promise<{ - binding: { noteVisibilityResume: () => void } - transport: MockTransport - typeKeystroke: (data?: string) => void - }> { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - const pane = createPane(2) - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - noteVisibilityResume: () => void - } - return { - binding, - transport, - // Drives the real xterm onData (terminal input) handler. - typeKeystroke: (data = 'a') => sendTerminalInputThroughPane(pane, data) - } - } - - it('does not fire listSessions for first input on a fresh mount', async () => { - const listSessions = vi.mocked(window.api.pty.listSessions) - listSessions.mockClear() - const { typeKeystroke } = await connectActivePaneWithInput() - - for (let i = 0; i < 25; i++) { - typeKeystroke('x') - } - - expect(listSessions).not.toHaveBeenCalled() - }) - - it('does not fire listSessions for input after a visibility resume', async () => { - const listSessions = vi.mocked(window.api.pty.listSessions) - listSessions.mockClear() - const { binding, typeKeystroke } = await connectActivePaneWithInput() - - binding.noteVisibilityResume() - typeKeystroke('a') - typeKeystroke('b') - - expect(listSessions).not.toHaveBeenCalled() - }) - - it('sends the first post-resume input without starting the liveness re-check', async () => { - const listSessions = vi.mocked(window.api.pty.listSessions) - listSessions.mockClear() - const calls: string[] = [] - listSessions.mockImplementation(() => { - calls.push('listSessions') - return Promise.resolve([]) - }) - const { binding, transport, typeKeystroke } = await connectActivePaneWithInput() - transport.sendInput.mockImplementation(() => { - calls.push('sendInput') - return true - }) - - binding.noteVisibilityResume() - typeKeystroke('a') - - expect(calls).toEqual(['sendInput']) - }) - - it('does not re-arm input-driven listSessions across repeated visibility resumes', async () => { - const listSessions = vi.mocked(window.api.pty.listSessions) - listSessions.mockClear() - const { binding, typeKeystroke } = await connectActivePaneWithInput() - - binding.noteVisibilityResume() - typeKeystroke('a') - typeKeystroke('b') - expect(listSessions).not.toHaveBeenCalled() - - binding.noteVisibilityResume() - typeKeystroke('c') - typeKeystroke('d') - expect(listSessions).not.toHaveBeenCalled() - }) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.spec.ts new file mode 100644 index 00000000000..34b9936a8d4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.spec.ts @@ -0,0 +1,623 @@ +import { bindPtyConnectionTestMocks, ptyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { createRect, createMeasuredElement } from './pty-connection-test-dom' +import { + createMockTransport, + createPane, + captureCallbackTerminalWrites, + createManager +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionSetupSplitSpawnTests(): void { + const { notifyCodexPaneBoundForStaleSweep } = ptyConnectionTestMocks + + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('clears the mobile-fit pane binding when the pane connection is disposed', async () => { + const { connectPanePty } = await import('./pty-connection') + const { getFitOverrideForPane, setFitOverride } = + await import('@/lib/pane-manager/mobile-fit-overrides') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pane = createPane(1) + + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps() as never + ) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + expect(onPtySpawn).toBeTypeOf('function') + onPtySpawn?.('pty-fit') + setFitOverride('pty-fit', 'mobile-fit', 49, 20) + + expect(getFitOverrideForPane(1, 'tab-1')).toEqual({ mode: 'mobile-fit', cols: 49, rows: 20 }) + expect(pane.container.dataset.ptyId).toBe('pty-fit') + + binding.dispose() + + expect(getFitOverrideForPane(1, 'tab-1')).toBeNull() + expect(pane.container.dataset.ptyId).toBeUndefined() + }) + + it('refits immediately when binding to a PTY with an active mobile-fit override', async () => { + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pane = createPane(1) + Object.defineProperty(pane.container, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + width: 800, + height: 400, + top: 0, + left: 0, + right: 800, + bottom: 400 + }) as DOMRect + }) + ;( + pane.fitAddon as unknown as { proposeDimensions: () => { cols: number; rows: number } } + ).proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + pane.terminal.resize.mockImplementation((cols: number, rows: number) => { + pane.terminal.cols = cols + pane.terminal.rows = rows + }) + setFitOverride('pty-fit', 'mobile-fit', 49, 20) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + expect(onPtySpawn).toBeTypeOf('function') + onPtySpawn?.('pty-fit') + + expect(pane.terminal.resize).toHaveBeenCalledWith(49, 20) + expect(pane.terminal.cols).toBe(49) + expect(pane.terminal.rows).toBe(20) + }) + + it('continues post-spawn size reconcile after a transient mobile presence lock', async () => { + const frameCallbacks: FrameRequestCallback[] = [] + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const runNextFrame = (): void => { + const callback = frameCallbacks.shift() + if (!callback) { + throw new Error('expected a queued animation frame') + } + callback(0) + } + + const { connectPanePty } = await import('./pty-connection') + const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') + + const ptyId = 'pty-post-spawn-transient-lock' + setDriverForPty(ptyId, { kind: 'mobile', clientId: 'phone-1' }) + try { + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 24 + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + runNextFrame() + await flushAsyncTicks() + + pane.terminal.cols = 120 + pane.terminal.rows = 40 + runNextFrame() + expect(transport.resize).not.toHaveBeenCalled() + + setDriverForPty(ptyId, { kind: 'idle' }) + runNextFrame() + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + } finally { + setDriverForPty(ptyId, { kind: 'idle' }) + } + }) + + it('waits for setup-split geometry before spawning the initial startup command', async () => { + const frameCallbacks: FrameRequestCallback[] = [] + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const runNextFrame = (): void => { + const callback = frameCallbacks.shift() + if (!callback) { + throw new Error('expected a queued animation frame') + } + callback(0) + } + + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } + + const pane = createPane(1) + const siblingPane = createPane(2) + let panes = [pane] + let proposedGrid = { cols: 240, rows: 50 } + let splitMounted = false + const root = createMeasuredElement({ rect: () => createRect(1200, 800) }) + const split = createMeasuredElement({ + className: () => (splitMounted ? 'pane-split is-vertical' : ''), + rect: () => createRect(1200, 800) + }) + const mainContainer = createMeasuredElement({ + parentElement: () => (splitMounted ? split : root), + rect: () => (splitMounted ? createRect(600, 800) : createRect(1200, 800)) + }) + const setupContainer = createMeasuredElement({ + parentElement: () => (splitMounted ? split : null), + rect: () => createRect(599, 800, 601, 0) + }) + pane.container = mainContainer + siblingPane.container = setupContainer + ;( + pane.fitAddon as unknown as { + proposeDimensions: () => { cols: number; rows: number } + } + ).proposeDimensions = vi.fn(() => proposedGrid) + pane.fitAddon.fit = vi.fn(() => { + pane.terminal.cols = proposedGrid.cols + pane.terminal.rows = proposedGrid.rows + }) + + const manager = createManager(1) + manager.getPanes = vi.fn(() => panes) + connectPanePty( + pane as never, + manager as never, + createDeps({ + startup: { command: 'codex', waitForSetupSplitDirection: 'vertical' } + }) as never + ) + + runNextFrame() + for (let i = 0; i < 8; i++) { + runNextFrame() + } + expect(transport.connect).not.toHaveBeenCalled() + + splitMounted = true + panes = [pane, siblingPane] + proposedGrid = { cols: 120, rows: 50 } + let postSplitFrames = 0 + while (frameCallbacks.length > 0 && transport.connect.mock.calls.length === 0) { + if (postSplitFrames >= 12) { + throw new Error('startup did not connect after setup split became ready') + } + postSplitFrames += 1 + runNextFrame() + } + + expect(createdTransportOptions[0]?.command).toBe('codex') + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ cols: 120, rows: 50 }) + ) + }) + + it.each(['before', 'during'] as const)( + 'spawns a startup command without waiting for frames when the window is hidden %s the grid settle', + async (hiddenWhen) => { + // A hidden window produces no animation frames, so only the queued ones below ever run. + const frameCallbacks: FrameRequestCallback[] = [] + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const visibilityListeners: (() => void)[] = [] + const fakeDocument = { + visibilityState: hiddenWhen === 'before' ? 'hidden' : 'visible', + addEventListener: vi.fn((type: string, listener: () => void) => { + if (type === 'visibilitychange') { + visibilityListeners.push(listener) + } + }), + removeEventListener: vi.fn() + } + vi.stubGlobal('document', fakeDocument) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.container = createMeasuredElement({ rect: () => createRect(1200, 800) }) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + pane.fitAddon.fit = vi.fn(() => { + pane.terminal.cols = 120 + pane.terminal.rows = 40 + }) + const deps = createDeps({ startup: { command: 'claude' } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pane, manager and deps doubles implement every member connectPanePty reads on this startup path. + const args = [pane, createManager(1), deps] as unknown as Parameters + connectPanePty(...args) + + if (hiddenWhen === 'before') { + vi.advanceTimersByTime(250) + } else { + for (let i = 0; i < 3; i++) { + frameCallbacks.shift()?.(0) + } + expect(transport.connect).not.toHaveBeenCalled() + fakeDocument.visibilityState = 'hidden' + for (const listener of visibilityListeners) { + listener() + } + } + + expect(createdTransportOptions[0]?.command).toBe('claude') + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ cols: 120, rows: 40 }) + ) + } + ) + + it('spawns the delayed main setup-split pane when its sibling owns the tab PTY fallback', async () => { + const frameCallbacks: FrameRequestCallback[] = [] + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const runNextFrame = (): void => { + const callback = frameCallbacks.shift() + if (!callback) { + throw new Error('expected a queued animation frame') + } + callback(0) + } + + const { connectPanePty } = await import('./pty-connection') + let mainPtyId: string | null = null + const mainTransport = createMockTransport() + mainTransport.getPtyId.mockImplementation(() => mainPtyId) + mainTransport.attach.mockImplementation(({ existingPtyId }: { existingPtyId: string }) => { + mainPtyId = existingPtyId + }) + mainTransport.connect.mockImplementation(async () => { + mainPtyId = 'pty-main' + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.(mainPtyId) + return mainPtyId + }) + let setupPtyId: string | null = null + const setupTransport = createMockTransport() + setupTransport.getPtyId.mockImplementation(() => setupPtyId) + setupTransport.connect.mockImplementation(async () => { + setupPtyId = 'pty-setup' + const onPtySpawn = createdTransportOptions[1]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.(setupPtyId) + return setupPtyId + }) + transportFactoryQueue.push(mainTransport, setupTransport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } + const updateStoreTabPtyId = vi.fn((_tabId: string, ptyId: string) => { + mockStoreState.tabsByWorktree['wt-1'][0].ptyId = ptyId + const livePtyIds = mockStoreState.ptyIdsByTabId?.['tab-1'] ?? [] + if (!livePtyIds.includes(ptyId)) { + livePtyIds.push(ptyId) + } + }) + + const mainPane = createPane(1) + const setupPane = createPane(2) + let splitMounted = false + const root = createMeasuredElement({ rect: () => createRect(1200, 800) }) + const split = createMeasuredElement({ + className: () => (splitMounted ? 'pane-split is-vertical' : ''), + rect: () => createRect(1200, 800) + }) + const mainContainer = createMeasuredElement({ + parentElement: () => (splitMounted ? split : root), + rect: () => (splitMounted ? createRect(600, 800) : createRect(1200, 800)) + }) + const setupContainer = createMeasuredElement({ + parentElement: () => split, + rect: () => createRect(599, 800, 601, 0) + }) + mainPane.container = mainContainer + setupPane.container = setupContainer + const manager = createManager(2) + manager.getPanes = vi.fn(() => [mainPane, setupPane]) + const sharedTransportsRef = { current: new Map() } + + connectPanePty( + mainPane as never, + manager as never, + createDeps({ + startup: { command: 'codex', waitForSetupSplitDirection: 'vertical' }, + paneTransportsRef: sharedTransportsRef, + updateTabPtyId: updateStoreTabPtyId + }) as never + ) + connectPanePty( + setupPane as never, + manager as never, + createDeps({ + startup: { command: 'bash setup-runner.sh' }, + paneTransportsRef: sharedTransportsRef, + updateTabPtyId: updateStoreTabPtyId + }) as never + ) + + for (let frame = 0; frame < 40 && setupTransport.connect.mock.calls.length === 0; frame++) { + runNextFrame() + } + expect(setupTransport.connect).toHaveBeenCalledTimes(1) + expect(mockStoreState.tabsByWorktree['wt-1'][0].ptyId).toBe('pty-setup') + expect(mainTransport.connect).not.toHaveBeenCalled() + + splitMounted = true + for ( + let frame = 0; + frame < 20 && + mainTransport.connect.mock.calls.length === 0 && + mainTransport.attach.mock.calls.length === 0; + frame++ + ) { + runNextFrame() + } + + expect(mainTransport.attach).not.toHaveBeenCalled() + expect(mainTransport.connect).toHaveBeenCalledTimes(1) + expect(mainTransport.connect).toHaveBeenCalledWith( + expect.not.objectContaining({ sessionId: expect.any(String) }) + ) + expect(createdTransportOptions[0]?.command).toBe('codex') + expect(createdTransportOptions[1]?.command).toBe('bash setup-runner.sh') + expect(mainPtyId).toBe('pty-main') + expect(setupPtyId).toBe('pty-setup') + }) + + it('does not reuse a sibling split pane pending spawn after remount', async () => { + const { connectPanePty } = await import('./pty-connection') + + const mainSpawn = createDeferred() + const setupSpawn = createDeferred() + + const mainTransport = createMockTransport() + mainTransport.connect.mockImplementation(async () => mainSpawn.promise) + const setupTransport = createMockTransport() + setupTransport.connect.mockImplementation(async () => setupSpawn.promise) + const remountTransport = createMockTransport() + transportFactoryQueue.push(mainTransport, setupTransport, remountTransport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: null }] + } + + const sharedTransportsRef = { current: new Map() } + connectPanePty( + createPane(1) as never, + createManager(2) as never, + createDeps({ paneTransportsRef: sharedTransportsRef }) as never + ) + connectPanePty( + createPane(2) as never, + createManager(2) as never, + createDeps({ + startup: { command: 'bash setup-runner.sh' }, + paneTransportsRef: sharedTransportsRef + }) as never + ) + + const remountDeps = createDeps() + const remountPane = createPane(1) + connectPanePty(remountPane as never, createManager(2) as never, remountDeps as never) + + setupSpawn.resolve('pty-setup') + mainSpawn.resolve('pty-main') + for (let i = 0; i < 20; i++) { + await Promise.resolve() + } + + expect(remountTransport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: 'pty-main' }) + ) + expect(remountPane.container.dataset.ptyId).toBe('pty-main') + expect(remountDeps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-main') + expect(remountDeps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-main') + }) + + it('binds a fresh spawn that resolves as a daemon reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + let currentPtyId: string | null = null + const transport = createMockTransport() + transport.getPtyId.mockImplementation(() => currentPtyId) + transport.connect.mockImplementation(async () => { + currentPtyId = 'pty-daemon-reattach' + return { id: currentPtyId, isReattach: true } + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } + + const pane = createPane(1) + const deps = createDeps() + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(pane.container.dataset.ptyId).toBe('pty-daemon-reattach') + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-daemon-reattach') + expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-daemon-reattach') + // Why: the restored shell keeps the CODEX_HOME it was spawned with, and this + // bind is the first moment the daemon PTY can be inspected for it. + expect(notifyCodexPaneBoundForStaleSweep).toHaveBeenCalledWith('pty-daemon-reattach') + }) + + it('replays a stable-pane adoption without submitting the resume command', async () => { + const { connectPanePty } = await import('./pty-connection') + const stablePtyId = 'stable-pane-session' + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ callbacks }) => { + callbacks.onReattachDetermined?.() + transport.getPtyId.mockReturnValue(stablePtyId) + callbacks.onData?.('NEWER-LIVE-OUTPUT') + return { + id: stablePtyId, + isReattach: true, + replay: 'ORIGINAL-LIVE-OUTPUT' + } + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + startup: { command: 'codex resume provider-session' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(4) + if (!onDataHandler || parseCallbacks.length === 0) { + throw new Error('expected replay and terminal input handlers') + } + ;(onDataHandler as (data: string) => void)('DURING_ADOPTION_REPLAY\r') + expect(transport.sendInput).not.toHaveBeenCalledWith('DURING_ADOPTION_REPLAY\r') + for (let step = 0; step < 30; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + ;(onDataHandler as (data: string) => void)('AFTER_ADOPTION_REPLAY\r') + + expect(pane.container.dataset.ptyId).toBe(stablePtyId) + expect(writes.join('')).toContain('ORIGINAL-LIVE-OUTPUT') + expect(writes.join('').indexOf('ORIGINAL-LIVE-OUTPUT')).toBeLessThan( + writes.join('').indexOf('NEWER-LIVE-OUTPUT') + ) + expect(transport.sendInput).not.toHaveBeenCalledWith('codex resume provider-session\r') + expect(transport.sendInput).toHaveBeenCalledWith('AFTER_ADOPTION_REPLAY\r', 'query-reply') + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, stablePtyId) + }) + + it('drops xterm onData while pane is replaying restored bytes', async () => { + // Regression: during replay, xterm auto-replies to embedded queries (DA1/DECRQM/OSC/CPR) via onData must not reach transport.sendInput or they land as stray chars on the prompt. See replay-guard.ts. + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport('pty-live') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const replayingPanesRef = { current: new Map([[1, 1]]) } + const deps = createDeps({ replayingPanesRef }) + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + // Simulate xterm emitting a DA1 auto-reply during replay parse. + ;(onDataHandler as (data: string) => void)('\x1b[?1;2c') + expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.claimViewport).not.toHaveBeenCalled() + + // Once replay completes (guard cleared), real keystrokes flow through. + replayingPanesRef.current.delete(1) + setFitOverride('pty-live', 'remote-desktop-fit', 80, 24) + transport.claimViewport.mockClear() + ;(onDataHandler as (data: string) => void)('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') + expect(transport.claimViewport).toHaveBeenCalledTimes(1) + ;(onDataHandler as (data: string) => void)('b') + // The renderer stays parked until runtime convergence is acknowledged, so a second keystroke can retry a transient failed resize. + expect(transport.claimViewport).toHaveBeenCalledTimes(2) + setFitOverride('pty-live', 'desktop-fit', 0, 0) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts deleted file mode 100644 index 5bb1412c42a..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts +++ /dev/null @@ -1,706 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' -import { createRect, createMeasuredElement } from './pty-connection-test-dom' -import { - createMockTransport, - createPane, - captureCallbackTerminalWrites, - createManager -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import type { MockTransport } from './pty-connection-test-pane-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('clears the mobile-fit pane binding when the pane connection is disposed', async () => { - const { connectPanePty } = await import('./pty-connection') - const { getFitOverrideForPane, setFitOverride } = - await import('@/lib/pane-manager/mobile-fit-overrides') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const pane = createPane(1) - - const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - expect(onPtySpawn).toBeTypeOf('function') - onPtySpawn?.('pty-fit') - setFitOverride('pty-fit', 'mobile-fit', 49, 20) - - expect(getFitOverrideForPane(1, 'tab-1')).toEqual({ mode: 'mobile-fit', cols: 49, rows: 20 }) - expect(pane.container.dataset.ptyId).toBe('pty-fit') - - binding.dispose() - - expect(getFitOverrideForPane(1, 'tab-1')).toBeNull() - expect(pane.container.dataset.ptyId).toBeUndefined() - }) - - it('refits immediately when binding to a PTY with an active mobile-fit override', async () => { - const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const pane = createPane(1) - Object.defineProperty(pane.container, 'getBoundingClientRect', { - configurable: true, - value: () => - ({ - width: 800, - height: 400, - top: 0, - left: 0, - right: 800, - bottom: 400 - }) as DOMRect - }) - ;( - pane.fitAddon as unknown as { proposeDimensions: () => { cols: number; rows: number } } - ).proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) - pane.terminal.resize.mockImplementation((cols: number, rows: number) => { - pane.terminal.cols = cols - pane.terminal.rows = rows - }) - setFitOverride('pty-fit', 'mobile-fit', 49, 20) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - expect(onPtySpawn).toBeTypeOf('function') - onPtySpawn?.('pty-fit') - - expect(pane.terminal.resize).toHaveBeenCalledWith(49, 20) - expect(pane.terminal.cols).toBe(49) - expect(pane.terminal.rows).toBe(20) - }) - - it('continues post-spawn size reconcile after a transient mobile presence lock', async () => { - const frameCallbacks: FrameRequestCallback[] = [] - globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - frameCallbacks.push(callback) - return frameCallbacks.length - }) - const runNextFrame = (): void => { - const callback = frameCallbacks.shift() - if (!callback) { - throw new Error('expected a queued animation frame') - } - callback(0) - } - - const { connectPanePty } = await import('./pty-connection') - const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') - - const ptyId = 'pty-post-spawn-transient-lock' - setDriverForPty(ptyId, { kind: 'mobile', clientId: 'phone-1' }) - try { - const transport = createMockTransport(ptyId) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - ptyIdsByTabId: { 'tab-1': [] } - } - const pane = createPane(1) - pane.terminal.cols = 80 - pane.terminal.rows = 24 - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - runNextFrame() - await flushAsyncTicks() - - pane.terminal.cols = 120 - pane.terminal.rows = 40 - runNextFrame() - expect(transport.resize).not.toHaveBeenCalled() - - setDriverForPty(ptyId, { kind: 'idle' }) - runNextFrame() - - expect(transport.resize).toHaveBeenCalledWith(120, 40) - } finally { - setDriverForPty(ptyId, { kind: 'idle' }) - } - }) - - it('waits for setup-split geometry before spawning the initial startup command', async () => { - const frameCallbacks: FrameRequestCallback[] = [] - globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - frameCallbacks.push(callback) - return frameCallbacks.length - }) - const runNextFrame = (): void => { - const callback = frameCallbacks.shift() - if (!callback) { - throw new Error('expected a queued animation frame') - } - callback(0) - } - - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - ptyIdsByTabId: { 'tab-1': [] } - } - - const pane = createPane(1) - const siblingPane = createPane(2) - let panes = [pane] - let proposedGrid = { cols: 240, rows: 50 } - let splitMounted = false - const root = createMeasuredElement({ rect: () => createRect(1200, 800) }) - const split = createMeasuredElement({ - className: () => (splitMounted ? 'pane-split is-vertical' : ''), - rect: () => createRect(1200, 800) - }) - const mainContainer = createMeasuredElement({ - parentElement: () => (splitMounted ? split : root), - rect: () => (splitMounted ? createRect(600, 800) : createRect(1200, 800)) - }) - const setupContainer = createMeasuredElement({ - parentElement: () => (splitMounted ? split : null), - rect: () => createRect(599, 800, 601, 0) - }) - pane.container = mainContainer - siblingPane.container = setupContainer - ;( - pane.fitAddon as unknown as { - proposeDimensions: () => { cols: number; rows: number } - } - ).proposeDimensions = vi.fn(() => proposedGrid) - pane.fitAddon.fit = vi.fn(() => { - pane.terminal.cols = proposedGrid.cols - pane.terminal.rows = proposedGrid.rows - }) - - const manager = createManager(1) - manager.getPanes = vi.fn(() => panes) - connectPanePty( - pane as never, - manager as never, - createDeps({ - startup: { command: 'codex', waitForSetupSplitDirection: 'vertical' } - }) as never - ) - - runNextFrame() - for (let i = 0; i < 8; i++) { - runNextFrame() - } - expect(transport.connect).not.toHaveBeenCalled() - - splitMounted = true - panes = [pane, siblingPane] - proposedGrid = { cols: 120, rows: 50 } - let postSplitFrames = 0 - while (frameCallbacks.length > 0 && transport.connect.mock.calls.length === 0) { - if (postSplitFrames >= 12) { - throw new Error('startup did not connect after setup split became ready') - } - postSplitFrames += 1 - runNextFrame() - } - - expect(createdTransportOptions[0]?.command).toBe('codex') - expect(transport.connect).toHaveBeenCalledWith(expect.objectContaining({ cols: 120, rows: 50 })) - }) - - it.each(['before', 'during'] as const)( - 'spawns a startup command without waiting for frames when the window is hidden %s the grid settle', - async (hiddenWhen) => { - // A hidden window produces no animation frames, so only the queued ones below ever run. - const frameCallbacks: FrameRequestCallback[] = [] - globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - frameCallbacks.push(callback) - return frameCallbacks.length - }) - const visibilityListeners: (() => void)[] = [] - const fakeDocument = { - visibilityState: hiddenWhen === 'before' ? 'hidden' : 'visible', - addEventListener: vi.fn((type: string, listener: () => void) => { - if (type === 'visibilitychange') { - visibilityListeners.push(listener) - } - }), - removeEventListener: vi.fn() - } - vi.stubGlobal('document', fakeDocument) - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const pane = createPane(1) - pane.container = createMeasuredElement({ rect: () => createRect(1200, 800) }) - pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) - pane.fitAddon.fit = vi.fn(() => { - pane.terminal.cols = 120 - pane.terminal.rows = 40 - }) - const deps = createDeps({ startup: { command: 'claude' } }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pane, manager and deps doubles implement every member connectPanePty reads on this startup path. - const args = [pane, createManager(1), deps] as unknown as Parameters - connectPanePty(...args) - - if (hiddenWhen === 'before') { - vi.advanceTimersByTime(250) - } else { - for (let i = 0; i < 3; i++) { - frameCallbacks.shift()?.(0) - } - expect(transport.connect).not.toHaveBeenCalled() - fakeDocument.visibilityState = 'hidden' - for (const listener of visibilityListeners) { - listener() - } - } - - expect(createdTransportOptions[0]?.command).toBe('claude') - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ cols: 120, rows: 40 }) - ) - } - ) - - it('spawns the delayed main setup-split pane when its sibling owns the tab PTY fallback', async () => { - const frameCallbacks: FrameRequestCallback[] = [] - globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - frameCallbacks.push(callback) - return frameCallbacks.length - }) - const runNextFrame = (): void => { - const callback = frameCallbacks.shift() - if (!callback) { - throw new Error('expected a queued animation frame') - } - callback(0) - } - - const { connectPanePty } = await import('./pty-connection') - let mainPtyId: string | null = null - const mainTransport = createMockTransport() - mainTransport.getPtyId.mockImplementation(() => mainPtyId) - mainTransport.attach.mockImplementation(({ existingPtyId }: { existingPtyId: string }) => { - mainPtyId = existingPtyId - }) - mainTransport.connect.mockImplementation(async () => { - mainPtyId = 'pty-main' - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - onPtySpawn?.(mainPtyId) - return mainPtyId - }) - let setupPtyId: string | null = null - const setupTransport = createMockTransport() - setupTransport.getPtyId.mockImplementation(() => setupPtyId) - setupTransport.connect.mockImplementation(async () => { - setupPtyId = 'pty-setup' - const onPtySpawn = createdTransportOptions[1]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - onPtySpawn?.(setupPtyId) - return setupPtyId - }) - transportFactoryQueue.push(mainTransport, setupTransport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - ptyIdsByTabId: { 'tab-1': [] } - } - const updateStoreTabPtyId = vi.fn((_tabId: string, ptyId: string) => { - mockStoreState.tabsByWorktree['wt-1'][0].ptyId = ptyId - const livePtyIds = mockStoreState.ptyIdsByTabId?.['tab-1'] ?? [] - if (!livePtyIds.includes(ptyId)) { - livePtyIds.push(ptyId) - } - }) - - const mainPane = createPane(1) - const setupPane = createPane(2) - let splitMounted = false - const root = createMeasuredElement({ rect: () => createRect(1200, 800) }) - const split = createMeasuredElement({ - className: () => (splitMounted ? 'pane-split is-vertical' : ''), - rect: () => createRect(1200, 800) - }) - const mainContainer = createMeasuredElement({ - parentElement: () => (splitMounted ? split : root), - rect: () => (splitMounted ? createRect(600, 800) : createRect(1200, 800)) - }) - const setupContainer = createMeasuredElement({ - parentElement: () => split, - rect: () => createRect(599, 800, 601, 0) - }) - mainPane.container = mainContainer - setupPane.container = setupContainer - const manager = createManager(2) - manager.getPanes = vi.fn(() => [mainPane, setupPane]) - const sharedTransportsRef = { current: new Map() } - - connectPanePty( - mainPane as never, - manager as never, - createDeps({ - startup: { command: 'codex', waitForSetupSplitDirection: 'vertical' }, - paneTransportsRef: sharedTransportsRef, - updateTabPtyId: updateStoreTabPtyId - }) as never - ) - connectPanePty( - setupPane as never, - manager as never, - createDeps({ - startup: { command: 'bash setup-runner.sh' }, - paneTransportsRef: sharedTransportsRef, - updateTabPtyId: updateStoreTabPtyId - }) as never - ) - - for (let frame = 0; frame < 40 && setupTransport.connect.mock.calls.length === 0; frame++) { - runNextFrame() - } - expect(setupTransport.connect).toHaveBeenCalledTimes(1) - expect(mockStoreState.tabsByWorktree['wt-1'][0].ptyId).toBe('pty-setup') - expect(mainTransport.connect).not.toHaveBeenCalled() - - splitMounted = true - for ( - let frame = 0; - frame < 20 && - mainTransport.connect.mock.calls.length === 0 && - mainTransport.attach.mock.calls.length === 0; - frame++ - ) { - runNextFrame() - } - - expect(mainTransport.attach).not.toHaveBeenCalled() - expect(mainTransport.connect).toHaveBeenCalledTimes(1) - expect(mainTransport.connect).toHaveBeenCalledWith( - expect.not.objectContaining({ sessionId: expect.any(String) }) - ) - expect(createdTransportOptions[0]?.command).toBe('codex') - expect(createdTransportOptions[1]?.command).toBe('bash setup-runner.sh') - expect(mainPtyId).toBe('pty-main') - expect(setupPtyId).toBe('pty-setup') - }) - - it('does not reuse a sibling split pane pending spawn after remount', async () => { - const { connectPanePty } = await import('./pty-connection') - - const mainSpawn = createDeferred() - const setupSpawn = createDeferred() - - const mainTransport = createMockTransport() - mainTransport.connect.mockImplementation(async () => mainSpawn.promise) - const setupTransport = createMockTransport() - setupTransport.connect.mockImplementation(async () => setupSpawn.promise) - const remountTransport = createMockTransport() - transportFactoryQueue.push(mainTransport, setupTransport, remountTransport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: null }] - } - - const sharedTransportsRef = { current: new Map() } - connectPanePty( - createPane(1) as never, - createManager(2) as never, - createDeps({ paneTransportsRef: sharedTransportsRef }) as never - ) - connectPanePty( - createPane(2) as never, - createManager(2) as never, - createDeps({ - startup: { command: 'bash setup-runner.sh' }, - paneTransportsRef: sharedTransportsRef - }) as never - ) - - const remountDeps = createDeps() - const remountPane = createPane(1) - connectPanePty(remountPane as never, createManager(2) as never, remountDeps as never) - - setupSpawn.resolve('pty-setup') - mainSpawn.resolve('pty-main') - for (let i = 0; i < 20; i++) { - await Promise.resolve() - } - - expect(remountTransport.attach).toHaveBeenCalledWith( - expect.objectContaining({ existingPtyId: 'pty-main' }) - ) - expect(remountPane.container.dataset.ptyId).toBe('pty-main') - expect(remountDeps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-main') - expect(remountDeps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-main') - }) - - it('binds a fresh spawn that resolves as a daemon reattach', async () => { - const { connectPanePty } = await import('./pty-connection') - let currentPtyId: string | null = null - const transport = createMockTransport() - transport.getPtyId.mockImplementation(() => currentPtyId) - transport.connect.mockImplementation(async () => { - currentPtyId = 'pty-daemon-reattach' - return { id: currentPtyId, isReattach: true } - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - ptyIdsByTabId: { 'tab-1': [] } - } - - const pane = createPane(1) - const deps = createDeps() - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks() - - expect(pane.container.dataset.ptyId).toBe('pty-daemon-reattach') - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-daemon-reattach') - expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-daemon-reattach') - // Why: the restored shell keeps the CODEX_HOME it was spawned with, and this - // bind is the first moment the daemon PTY can be inspected for it. - expect(notifyCodexPaneBoundForStaleSweep).toHaveBeenCalledWith('pty-daemon-reattach') - }) - - it('replays a stable-pane adoption without submitting the resume command', async () => { - const { connectPanePty } = await import('./pty-connection') - const stablePtyId = 'stable-pane-session' - const transport = createMockTransport() - transport.connect.mockImplementation(async ({ callbacks }) => { - callbacks.onReattachDetermined?.() - transport.getPtyId.mockReturnValue(stablePtyId) - callbacks.onData?.('NEWER-LIVE-OUTPUT') - return { - id: stablePtyId, - isReattach: true, - replay: 'ORIGINAL-LIVE-OUTPUT' - } - }) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - ptyIdsByTabId: { 'tab-1': [] } - } - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) - const deps = createDeps({ - startup: { command: 'codex resume provider-session' } - }) - - connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(4) - if (!onDataHandler || parseCallbacks.length === 0) { - throw new Error('expected replay and terminal input handlers') - } - ;(onDataHandler as (data: string) => void)('DURING_ADOPTION_REPLAY\r') - expect(transport.sendInput).not.toHaveBeenCalledWith('DURING_ADOPTION_REPLAY\r') - for (let step = 0; step < 30; step += 1) { - parseCallbacks.shift()?.() - await flushAsyncTicks(2) - } - ;(onDataHandler as (data: string) => void)('AFTER_ADOPTION_REPLAY\r') - - expect(pane.container.dataset.ptyId).toBe(stablePtyId) - expect(writes.join('')).toContain('ORIGINAL-LIVE-OUTPUT') - expect(writes.join('').indexOf('ORIGINAL-LIVE-OUTPUT')).toBeLessThan( - writes.join('').indexOf('NEWER-LIVE-OUTPUT') - ) - expect(transport.sendInput).not.toHaveBeenCalledWith('codex resume provider-session\r') - expect(transport.sendInput).toHaveBeenCalledWith('AFTER_ADOPTION_REPLAY\r', 'query-reply') - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, stablePtyId) - }) - - it('drops xterm onData while pane is replaying restored bytes', async () => { - // Regression: during replay, xterm auto-replies to embedded queries (DA1/DECRQM/OSC/CPR) via onData must not reach transport.sendInput or they land as stray chars on the prompt. See replay-guard.ts. - const { connectPanePty } = await import('./pty-connection') - - const transport = createMockTransport('pty-live') - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const replayingPanesRef = { current: new Map([[1, 1]]) } - const deps = createDeps({ replayingPanesRef }) - const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') - - connectPanePty(pane as never, manager as never, deps as never) - - expect(onDataHandler).toBeDefined() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - // Simulate xterm emitting a DA1 auto-reply during replay parse. - ;(onDataHandler as (data: string) => void)('\x1b[?1;2c') - expect(transport.sendInput).not.toHaveBeenCalled() - expect(transport.claimViewport).not.toHaveBeenCalled() - - // Once replay completes (guard cleared), real keystrokes flow through. - replayingPanesRef.current.delete(1) - setFitOverride('pty-live', 'remote-desktop-fit', 80, 24) - transport.claimViewport.mockClear() - ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') - expect(transport.claimViewport).toHaveBeenCalledTimes(1) - ;(onDataHandler as (data: string) => void)('b') - // The renderer stays parked until runtime convergence is acknowledged, so a second keystroke can retry a transient failed resize. - expect(transport.claimViewport).toHaveBeenCalledTimes(2) - setFitOverride('pty-live', 'desktop-fit', 0, 0) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts new file mode 100644 index 00000000000..e973be96497 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts @@ -0,0 +1,393 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { UUID_RE } from './pty-connection-test-constants' +import { + LEAF_2, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionSleepingResumeBannerTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('resumes a local sleeping pane in place when the restored PTY hint is missing', async () => { + const { connectPanePty } = await import('./pty-connection') + const spawn = createDeferred() + const transport = createMockTransport() + transport.connect.mockImplementation(() => spawn.promise) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_2) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null }] + }, + ptyIdsByTabId: { + 'tab-1': [] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: {} + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(4) + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", + launchAgent: 'codex', + env: expect.objectContaining({ + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1', + ORCA_WORKSPACE_ID: 'wt-1', + ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + }) + }) + ) + expect(transport.connect).toHaveBeenCalledWith( + expect.not.objectContaining({ sessionId: expect.any(String) }) + ) + expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith( + paneKey, + { + agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'", + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: {} + }, + { + agentType: 'codex', + launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)), + tabId: 'tab-1', + leafId: LEAF_2 + } + ) + expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('fresh-pty') + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'fresh-pty') + expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'fresh-pty') + expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() + + spawn.resolve('fresh-pty') + await flushAsyncTicks(10) + + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'restored') + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) + }) + + it('says the fresh-spawn resume started fresh when main declined it', async () => { + // Why: the primary #10757 path — the sidebar resumes a sleeping agent into a new tab, + // so there is no restored PTY and the spawn is fresh, not a cold restore. + const { connectPanePty } = await import('./pty-connection') + const spawn = createDeferred<{ id: string; agentResumeUnavailable: true }>() + const transport = createMockTransport() + transport.connect.mockImplementation(() => spawn.promise) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_2) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null }] + }, + ptyIdsByTabId: { + 'tab-1': [] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: {} + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(4) + + expect(transport.connect).toHaveBeenCalledWith( + expect.not.objectContaining({ sessionId: expect.any(String) }) + ) + + spawn.resolve({ id: 'fresh-pty', agentResumeUnavailable: true }) + await flushAsyncTicks(10) + + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'resume-unavailable') + }) + + it('lets a declined resume replace an already-shown restored banner', async () => { + // Why: the banner latch is one-shot per pane. A pane that showed "session restored" + // and then respawned into a session main declined must stop claiming the restore. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + let connectCount = 0 + transport.connect.mockImplementation(async () => { + connectCount += 1 + // The wake respawn is the one main declines; the first spawn is an ordinary resume. + const spawnedPtyId = connectCount === 1 ? 'fresh-pty' : 'woken-pty' + transport.getPtyId.mockReturnValue(spawnedPtyId) + return connectCount === 1 + ? spawnedPtyId + : { id: spawnedPtyId, agentResumeUnavailable: true as const } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_2) + const sleepingRecord = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'done' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' as const + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null }] + }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { [paneKey]: sleepingRecord }, + suppressedPtyExitIds: { 'fresh-pty': true } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: {}, + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + noteVisibilityResume: () => void + } + await flushAsyncTicks(10) + + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'restored') + + // Hibernate the pane, then reveal it so the wake respawns the recorded session. + // Hibernation writes the sleeping record the first spawn consumed. + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = sleepingRecord + const onPtyExit = createdTransportOptions[0]?.onPtyExit as + | ((ptyId: string) => void) + | undefined + onPtyExit?.('fresh-pty') + await flushAsyncTicks(10) + binding.noteVisibilityResume() + await flushAsyncTicks(10) + + expect(connectCount).toBeGreaterThan(1) + expect(deps.onShowSessionRestoredBanner).toHaveBeenLastCalledWith(2, 'resume-unavailable') + }) + + it('keeps sleeping resume record when fresh cold-restore spawn fails', async () => { + const { connectPanePty } = await import('./pty-connection') + const staleSessionId = 'wt-1@@stale-session' + const transport = createMockTransport() + transport.connect.mockResolvedValue(undefined) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_2) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: staleSessionId }] + }, + ptyIdsByTabId: { + 'tab-1': [staleSessionId] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: staleSessionId } + } + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: staleSessionId } + }) + + connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.connect).toHaveBeenCalledTimes(2) + expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + }) + + it('forwards one sidebar resume spawn without writing the restored banner through xterm', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-1') + transportFactoryQueue.push(transport) + const pane = createPane(1) + const providerSession = { key: 'session_id', id: 'codex-session-1' } as const + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + startup: { + command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: providerSession, + showSessionRestoredBanner: true + } + }) as never + ) + await flushAsyncTicks(10) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('pty-1') + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(pane.terminal.write).not.toHaveBeenCalledWith( + expect.stringContaining('--- session restored ---'), + expect.any(Function) + ) + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(createdTransportOptions[0]).toMatchObject({ + command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: providerSession + }) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.test.ts deleted file mode 100644 index a767092c58e..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.test.ts +++ /dev/null @@ -1,482 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' -import { UUID_RE } from './pty-connection-test-constants' -import { - LEAF_2, - createMockTransport, - createPane, - createManager -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import type { MockTransport } from './pty-connection-test-pane-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('resumes a local sleeping pane in place when the restored PTY hint is missing', async () => { - const { connectPanePty } = await import('./pty-connection') - const spawn = createDeferred() - const transport = createMockTransport() - transport.connect.mockImplementation(() => spawn.promise) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_2) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: null }] - }, - ptyIdsByTabId: { - 'tab-1': [] - }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_2 }, - activeLeafId: LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {} - }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: {} - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(4) - - expect(transport.connect).toHaveBeenCalledTimes(1) - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ - command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", - launchAgent: 'codex', - env: expect.objectContaining({ - ORCA_PANE_KEY: paneKey, - ORCA_TAB_ID: 'tab-1', - ORCA_WORKTREE_ID: 'wt-1', - ORCA_WORKSPACE_ID: 'wt-1', - ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) - }) - }) - ) - expect(transport.connect).toHaveBeenCalledWith( - expect.not.objectContaining({ sessionId: expect.any(String) }) - ) - expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith( - paneKey, - { - agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'", - agentArgs: '--dangerously-bypass-approvals-and-sandbox', - agentEnv: {} - }, - { - agentType: 'codex', - launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)), - tabId: 'tab-1', - leafId: LEAF_2 - } - ) - expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() - expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() - - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - onPtySpawn?.('fresh-pty') - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'fresh-pty') - expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'fresh-pty') - expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() - - spawn.resolve('fresh-pty') - await flushAsyncTicks(10) - - expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) - expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'restored') - expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) - }) - - it('says the fresh-spawn resume started fresh when main declined it', async () => { - // Why: the primary #10757 path — the sidebar resumes a sleeping agent into a new tab, - // so there is no restored PTY and the spawn is fresh, not a cold restore. - const { connectPanePty } = await import('./pty-connection') - const spawn = createDeferred<{ id: string; agentResumeUnavailable: true }>() - const transport = createMockTransport() - transport.connect.mockImplementation(() => spawn.promise) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_2) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: null }] - }, - ptyIdsByTabId: { - 'tab-1': [] - }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_2 }, - activeLeafId: LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {} - }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: {} - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(4) - - expect(transport.connect).toHaveBeenCalledWith( - expect.not.objectContaining({ sessionId: expect.any(String) }) - ) - - spawn.resolve({ id: 'fresh-pty', agentResumeUnavailable: true }) - await flushAsyncTicks(10) - - expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) - expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'resume-unavailable') - }) - - it('lets a declined resume replace an already-shown restored banner', async () => { - // Why: the banner latch is one-shot per pane. A pane that showed "session restored" - // and then respawned into a session main declined must stop claiming the restore. - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - let connectCount = 0 - transport.connect.mockImplementation(async () => { - connectCount += 1 - // The wake respawn is the one main declines; the first spawn is an ordinary resume. - const spawnedPtyId = connectCount === 1 ? 'fresh-pty' : 'woken-pty' - transport.getPtyId.mockReturnValue(spawnedPtyId) - return connectCount === 1 - ? spawnedPtyId - : { id: spawnedPtyId, agentResumeUnavailable: true as const } - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_2) - const sleepingRecord = { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex' as const, - providerSession: { key: 'session_id' as const, id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'done' as const, - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' as const - } - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: null }] - }, - ptyIdsByTabId: { 'tab-1': [] }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_2 }, - activeLeafId: LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { [paneKey]: sleepingRecord }, - suppressedPtyExitIds: { 'fresh-pty': true } - } as StoreState - - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: {}, - consumeSuppressedPtyExit: vi.fn(() => true), - isVisibleRef: { current: false } - }) - - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - noteVisibilityResume: () => void - } - await flushAsyncTicks(10) - - expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'restored') - - // Hibernate the pane, then reveal it so the wake respawns the recorded session. - // Hibernation writes the sleeping record the first spawn consumed. - mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = sleepingRecord - const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined - onPtyExit?.('fresh-pty') - await flushAsyncTicks(10) - binding.noteVisibilityResume() - await flushAsyncTicks(10) - - expect(connectCount).toBeGreaterThan(1) - expect(deps.onShowSessionRestoredBanner).toHaveBeenLastCalledWith(2, 'resume-unavailable') - }) - - it('keeps sleeping resume record when fresh cold-restore spawn fails', async () => { - const { connectPanePty } = await import('./pty-connection') - const staleSessionId = 'wt-1@@stale-session' - const transport = createMockTransport() - transport.connect.mockResolvedValue(undefined) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_2) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: staleSessionId }] - }, - ptyIdsByTabId: { - 'tab-1': [staleSessionId] - }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_2 }, - activeLeafId: LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_2]: staleSessionId } - } - }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { [LEAF_2]: staleSessionId } - }) - - connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) - await flushAsyncTicks(20) - - expect(transport.connect).toHaveBeenCalledTimes(2) - expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() - expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() - expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() - }) - - it('forwards one sidebar resume spawn without writing the restored banner through xterm', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-1') - transportFactoryQueue.push(transport) - const pane = createPane(1) - const providerSession = { key: 'session_id', id: 'codex-session-1' } as const - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - startup: { - command: "codex 'resume' 'codex-session-1'", - resumeProviderSession: providerSession, - showSessionRestoredBanner: true - } - }) as never - ) - await flushAsyncTicks(10) - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - onPtySpawn?.('pty-1') - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(pane.terminal.write).not.toHaveBeenCalledWith( - expect.stringContaining('--- session restored ---'), - expect.any(Function) - ) - expect(transport.connect).toHaveBeenCalledTimes(1) - expect(createdTransportOptions[0]).toMatchObject({ - command: "codex 'resume' 'codex-session-1'", - resumeProviderSession: providerSession - }) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts new file mode 100644 index 00000000000..fed96cf0aad --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts @@ -0,0 +1,24 @@ +import { describe } from 'vitest' +import { registerPtyConnectionPendingSpawnTests } from './pty-connection-pending-spawn.spec' +import { registerPtyConnectionFreshSpawnGuardsTests } from './pty-connection-fresh-spawn-guards.spec' +import { registerPtyConnectionSetupSplitSpawnTests } from './pty-connection-setup-split-spawn.spec' +import { registerPtyConnectionRuntimeOwnerSpawnRoutingTests } from './pty-connection-runtime-owner-spawn-routing.spec' +import { registerPtyConnectionStartupCommandDeliveryTests } from './pty-connection-startup-command-delivery.spec' +import { registerPtyConnectionStartupDraftDeliveryTests } from './pty-connection-startup-draft-delivery.spec' +import { registerPtyConnectionDeliberateSleepGuardTests } from './pty-connection-deliberate-sleep-guard.spec' +import { registerPtyConnectionRemoteRuntimeAttachTests } from './pty-connection-remote-runtime-attach.spec' + +describe('pty-connection-pending-spawn', registerPtyConnectionPendingSpawnTests) +describe('pty-connection-fresh-spawn-guards', registerPtyConnectionFreshSpawnGuardsTests) +describe('pty-connection-setup-split-spawn', registerPtyConnectionSetupSplitSpawnTests) +describe( + 'pty-connection-runtime-owner-spawn-routing', + registerPtyConnectionRuntimeOwnerSpawnRoutingTests +) +describe( + 'pty-connection-startup-command-delivery', + registerPtyConnectionStartupCommandDeliveryTests +) +describe('pty-connection-startup-draft-delivery', registerPtyConnectionStartupDraftDeliveryTests) +describe('pty-connection-deliberate-sleep-guard', registerPtyConnectionDeliberateSleepGuardTests) +describe('pty-connection-remote-runtime-attach', registerPtyConnectionRemoteRuntimeAttachTests) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.spec.ts new file mode 100644 index 00000000000..3b35c37ad03 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.spec.ts @@ -0,0 +1,800 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildKittyKeyboardRestore, + RESET_AFTER_BYTE_GAP +} from '../../../../shared/terminal-mode-reset-profiles' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { NORMAL_BUFFER_PROLOGUE } from './pty-connection-test-constants' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +// An abandon re-asserts the (known-zero) mirror's kitty flags after grounding the gap. +const ABANDONED_RESTORE_GAP = `${RESET_AFTER_BYTE_GAP}${buildKittyKeyboardRestore(0)}` + +export function registerPtyConnectionStalledHiddenRestoreTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('abandons a stalled hidden restore with reset, warning, then pending foreground', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() + getMainBufferSnapshot.mockReturnValue(snapshot.promise) + const hidden = 'hidden-codex-output\r\n' + const firstLive = 'first-live-output\r\n' + const secondLive = 'second-live-output\r\n' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(firstLive, { + seq: hidden.length + firstLive.length, + rawLength: firstLive.length + }) + await flushAsyncTicks(4) + capturedDataCallback.current?.(secondLive, { + seq: hidden.length + firstLive.length + secondLive.length, + rawLength: secondLive.length + }) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(firstLive, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(secondLive, expect.any(Function)) + + vi.advanceTimersByTime(749) + await flushAsyncTicks(4) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + expect.stringContaining('main recovery was unavailable'), + expect.any(Function) + ) + + vi.advanceTimersByTime(1) + vi.advanceTimersByTime(0) + await flushAsyncTicks(10) + + const written = pane.terminal.write.mock.calls.map(([data]) => data as string) + const warningIndex = written.findIndex((data) => + data.includes('main recovery was unavailable') + ) + const combinedLiveIndex = written.indexOf(firstLive + secondLive) + expect(warningIndex).toBeGreaterThanOrEqual(0) + expect(written[warningIndex - 1]).toBe(ABANDONED_RESTORE_GAP) + // Exactly one: writeRestoreUnavailableWarning already grounds the gap, so a + // second unconditional write here was pure duplication. + expect(written.filter((data) => data === ABANDONED_RESTORE_GAP)).toHaveLength(1) + expect(combinedLiveIndex).toBeGreaterThan(warningIndex) + + snapshot.resolve({ + data: 'late-snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + firstLive.length + secondLive.length + }) + await flushAsyncTicks(20) + + expect(pane.terminal.write).not.toHaveBeenCalledWith( + 'late-snapshot-state\r\n', + expect.any(Function) + ) + disposable.dispose() + }) + + // STA-4042: the hidden-delivery gate drops renderer-bound bytes, so the span it + // ate can contain the `ESC[22m` closing a bold run. Abandoning the restore means + // no snapshot will rebuild the buffer, so unless the pen is cleared here every + // drained and subsequent cell inherits bold — the "regular text renders bold" + // field report. + it('clears the SGR pen before draining abandoned foreground chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() + getMainBufferSnapshot.mockReturnValue(snapshot.promise) + // The dropped span is where `ESC[22m` would have been; the pen is left bold. + const hidden = '\x1b[1mbold-run-opened-while-hidden\r\n' + const live = 'live-after-reveal\r\n' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + vi.useFakeTimers() + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(4) + + // Let the foreground deadline expire so the restore is abandoned. + vi.advanceTimersByTime(750) + vi.advanceTimersByTime(0) + await flushAsyncTicks(10) + + const written = pane.terminal.write.mock.calls.map(([data]) => data as string) + const resetIndex = written.indexOf(ABANDONED_RESTORE_GAP) + const liveIndex = written.findIndex((data) => data.includes('live-after-reveal')) + expect(resetIndex).toBeGreaterThanOrEqual(0) + expect(liveIndex).toBeGreaterThanOrEqual(0) + expect(resetIndex).toBeLessThan(liveIndex) + disposable.dispose() + }) + + it('grounds byte-gap state before a remote restore re-arms', async () => { + const { connectPanePty } = await import('./pty-connection') + const remotePtyId = 'remote:env-1@@terminal-rearm' + const transport = createMockTransport(remotePtyId) + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return remotePtyId + } + ) + const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() + transport.serializeBuffer = vi.fn().mockReturnValue(snapshot.promise) + transportFactoryQueue.push(transport) + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'remote-live-after-rearm\r\n' + + const pane = createPane(1) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + vi.useFakeTimers() + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(4) + + vi.advanceTimersByTime(750) + await flushAsyncTicks(10) + + const written = pane.terminal.write.mock.calls.map(([data]) => data as string) + const resetIndex = written.indexOf(ABANDONED_RESTORE_GAP) + const liveIndex = written.indexOf(live) + expect(resetIndex).toBeGreaterThanOrEqual(0) + expect(liveIndex).toBeGreaterThan(resetIndex) + // The re-arm arm grounds in rearmRemoteHiddenOutputRestoreInsteadOfWarning, + // so the abandon body must not ground a second time. + expect(written.filter((data) => data === ABANDONED_RESTORE_GAP)).toHaveLength(1) + expect(written.join('')).not.toContain('main recovery was unavailable') + + disposable.dispose() + }) + + it('falls back after repeated null hidden restore retries and drains blocked foreground', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue(null) + const hidden = 'hidden-codex-output\r\n' + const live = 'visible-after-null-retries\r\n' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(10) + + for (let attempt = 0; attempt < 3; attempt++) { + expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) + vi.advanceTimersByTime(50) + vi.advanceTimersByTime(0) + await flushAsyncTicks(10) + } + + const written = pane.terminal.write.mock.calls.map(([data]) => data as string) + const warningIndex = written.findIndex((data) => + data.includes('main recovery was unavailable') + ) + const liveIndex = written.indexOf(live) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(4) + expect(warningIndex).toBeGreaterThanOrEqual(0) + expect(liveIndex).toBeGreaterThan(warningIndex) + // This arm gives up on recovery too, so the gap is grounded exactly once + // before the blocked foreground is drained under it. + expect(written.filter((data) => data === ABANDONED_RESTORE_GAP)).toHaveLength(1) + expect(written.indexOf(ABANDONED_RESTORE_GAP)).toBeLessThan(liveIndex) + disposable.dispose() + }) + + it('drops pending foreground overflow when a stalled hidden restore falls back', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockReturnValue( + createDeferred<{ data: string; cols: number; rows: number; seq: number }>().promise + ) + const hidden = 'hidden-codex-output\r\n' + const liveOverflow = 'v'.repeat(512 * 1024 + 1) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(liveOverflow, { + seq: hidden.length + liveOverflow.length, + rawLength: liveOverflow.length + }) + await flushAsyncTicks(4) + + await vi.advanceTimersByTimeAsync(750) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('main recovery was unavailable'), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith(liveOverflow, expect.any(Function)) + disposable.dispose() + }) + + it('coalesces tiny pending foreground chunks when stalled hidden restore falls back', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockReturnValue( + createDeferred<{ data: string; cols: number; rows: number; seq: number }>().promise + ) + const hidden = 'hidden-codex-output\r\n' + const chunkCount = 2_000 + const liveChunk = 'x' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + for (let index = 0; index < chunkCount; index += 1) { + capturedDataCallback.current?.(liveChunk, { + seq: hidden.length + index + 1, + rawLength: liveChunk.length + }) + } + await flushAsyncTicks(4) + + vi.advanceTimersByTime(750) + vi.advanceTimersByTime(0) + await flushAsyncTicks(10) + + const written = pane.terminal.write.mock.calls.map(([data]) => data as string) + const warningIndex = written.findIndex((data) => + data.includes('main recovery was unavailable') + ) + const combinedLive = liveChunk.repeat(chunkCount) + const liveWrites = written.filter((data) => data === combinedLive) + expect(warningIndex).toBeGreaterThanOrEqual(0) + expect(liveWrites).toHaveLength(1) + expect(written.indexOf(combinedLive)).toBeGreaterThan(warningIndex) + + disposable.dispose() + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps foreground output when hidden-backlog snapshot recovery is unavailable', async () => { + const pendingTimeouts: { + canceled: boolean + delay: number + fn: () => void + id: number + }[] = [] + const originalSetTimeout = globalThis.setTimeout + const originalClearTimeout = globalThis.clearTimeout + let nextTimeoutId = 1 + globalThis.setTimeout = vi.fn((fn: () => void, delay?: number) => { + const timeout = { + canceled: false, + delay: typeof delay === 'number' ? delay : 0, + fn, + id: nextTimeoutId++ + } + pendingTimeouts.push(timeout) + return timeout.id as unknown as ReturnType + }) as unknown as typeof setTimeout + globalThis.clearTimeout = vi.fn((id: ReturnType) => { + const numericId = id as unknown as number + const timeout = pendingTimeouts.find((candidate) => candidate.id === numericId) + if (timeout) { + timeout.canceled = true + } + }) as unknown as typeof clearTimeout + + const runNextTimeoutWithDelay = async (delay: number): Promise => { + const index = pendingTimeouts.findIndex( + (timeout) => !timeout.canceled && timeout.delay === delay + ) + expect(index).toBeGreaterThanOrEqual(0) + const [timeout] = pendingTimeouts.splice(index, 1) + timeout.fn() + await flushAsyncTicks(20) + } + const drainTimeoutsWithDelay = async (delay: number): Promise => { + while (pendingTimeouts.some((timeout) => !timeout.canceled && timeout.delay === delay)) { + await runNextTimeoutWithDelay(delay) + } + } + + let disposable: { dispose: () => void } | null = null + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue(null) + + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'foreground-after-unavailable\r\n' + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + expect.stringContaining(live), + expect.any(Function) + ) + + await runNextTimeoutWithDelay(50) + await runNextTimeoutWithDelay(50) + await runNextTimeoutWithDelay(50) + await drainTimeoutsWithDelay(0) + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(4) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining( + 'Orca skipped hidden terminal output because main recovery was unavailable.' + ), + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining(live), + expect.any(Function) + ) + } finally { + disposable?.dispose() + globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout + } + }) + + it('keeps a newer same-PTY hidden restore after a timed-out snapshot resolves late', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + const secondSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + getMainBufferSnapshot + .mockReturnValueOnce(firstSnapshot.promise) + .mockReturnValueOnce(secondSnapshot.promise) + const firstHidden = 'first-hidden-output\r\n' + const firstLive = 'first-live-output\r\n' + const secondHidden = 'second-hidden-output\r\n' + const secondLive = 'second-live-output\r\n' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() + capturedDataCallback.current?.(firstHidden, { + seq: firstHidden.length, + rawLength: firstHidden.length + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(firstLive, { + seq: firstHidden.length + firstLive.length, + rawLength: firstLive.length + }) + await flushAsyncTicks(4) + vi.advanceTimersByTime(750) + vi.advanceTimersByTime(0) + await flushAsyncTicks(10) + + pane.terminal.write.mockClear() + ;(deps.isVisibleRef as { current: boolean }).current = false + capturedDataCallback.current?.(secondHidden, { + seq: firstHidden.length + firstLive.length + secondHidden.length, + rawLength: secondHidden.length + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(secondLive, { + seq: firstHidden.length + firstLive.length + secondHidden.length + secondLive.length, + rawLength: secondLive.length + }) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + + firstSnapshot.resolve({ + data: 'stale-first-snapshot\r\n', + cols: 100, + rows: 30, + seq: firstHidden.length + firstLive.length + }) + await flushAsyncTicks(10) + secondSnapshot.resolve({ + data: 'fresh-second-snapshot\r\n', + cols: 100, + rows: 30, + seq: firstHidden.length + firstLive.length + secondHidden.length + secondLive.length + }) + await flushAsyncTicks(20) + + expect(pane.terminal.write).not.toHaveBeenCalledWith( + 'stale-first-snapshot\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + 'fresh-second-snapshot\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith(secondLive, expect.any(Function)) + disposable.dispose() + }) + + it('ignores an async hidden-backlog snapshot if the pane changes PTYs first', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('old-pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'old-pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() + getMainBufferSnapshot.mockReturnValue(snapshot.promise) + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'old-live-output\r\n' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(2) + transport.getPtyId.mockReturnValue('new-pty-id') + snapshot.resolve({ + data: 'old-snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('old-pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + 'old-snapshot-state\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) + disposable.dispose() + }) + + it('does not recover stale hidden backlog state after the pane switches PTYs', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('old-pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'old-pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const newPtyOutput = 'new-pty-output\r\n' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + transport.getPtyId.mockReturnValue('new-pty-id') + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(newPtyOutput, { + seq: newPtyOutput.length, + rawLength: newPtyOutput.length + }) + await flushAsyncTicks(10) + + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith(newPtyOutput, expect.any(Function)) + disposable.dispose() + }) + + it('does not replay pending hidden restore chunks after a terminal clear', async () => { + const clearBufferCallback: { + current: ((request: { ptyId: string }) => void) | null + } = { current: null } + window.api.pty.onClearBufferRequest = vi.fn((callback) => { + clearBufferCallback.current = callback as (request: { ptyId: string }) => void + return vi.fn() + }) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() + getMainBufferSnapshot.mockReturnValue(snapshot.promise) + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'pre-clear-live-output\r\n' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(2) + expect(clearBufferCallback.current).not.toBeNull() + + clearBufferCallback.current?.({ ptyId: 'pty-id' }) + snapshot.resolve({ + data: '', + cols: 120, + rows: 40, + seq: hidden.length + live.length + }) + await flushAsyncTicks(20) + + expect(pane.terminal.clear).toHaveBeenCalled() + expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + NORMAL_BUFFER_PROLOGUE, + expect.any(Function) + ) + disposable.dispose() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts deleted file mode 100644 index 42422174f1a..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts +++ /dev/null @@ -1,865 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - buildKittyKeyboardRestore, - RESET_AFTER_BYTE_GAP -} from '../../../../shared/terminal-mode-reset-profiles' -import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' -import { NORMAL_BUFFER_PROLOGUE } from './pty-connection-test-constants' -import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -// An abandon re-asserts the (known-zero) mirror's kitty flags after grounding the gap. -const ABANDONED_RESTORE_GAP = `${RESET_AFTER_BYTE_GAP}${buildKittyKeyboardRestore(0)}` - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('abandons a stalled hidden restore with reset, warning, then pending foreground', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() - getMainBufferSnapshot.mockReturnValue(snapshot.promise) - const hidden = 'hidden-codex-output\r\n' - const firstLive = 'first-live-output\r\n' - const secondLive = 'second-live-output\r\n' - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(firstLive, { - seq: hidden.length + firstLive.length, - rawLength: firstLive.length - }) - await flushAsyncTicks(4) - capturedDataCallback.current?.(secondLive, { - seq: hidden.length + firstLive.length + secondLive.length, - rawLength: secondLive.length - }) - - expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) - expect(pane.terminal.write).not.toHaveBeenCalledWith(firstLive, expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith(secondLive, expect.any(Function)) - - vi.advanceTimersByTime(749) - await flushAsyncTicks(4) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - expect.stringContaining('main recovery was unavailable'), - expect.any(Function) - ) - - vi.advanceTimersByTime(1) - vi.advanceTimersByTime(0) - await flushAsyncTicks(10) - - const written = pane.terminal.write.mock.calls.map(([data]) => data as string) - const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable')) - const combinedLiveIndex = written.indexOf(firstLive + secondLive) - expect(warningIndex).toBeGreaterThanOrEqual(0) - expect(written[warningIndex - 1]).toBe(ABANDONED_RESTORE_GAP) - // Exactly one: writeRestoreUnavailableWarning already grounds the gap, so a - // second unconditional write here was pure duplication. - expect(written.filter((data) => data === ABANDONED_RESTORE_GAP)).toHaveLength(1) - expect(combinedLiveIndex).toBeGreaterThan(warningIndex) - - snapshot.resolve({ - data: 'late-snapshot-state\r\n', - cols: 100, - rows: 30, - seq: hidden.length + firstLive.length + secondLive.length - }) - await flushAsyncTicks(20) - - expect(pane.terminal.write).not.toHaveBeenCalledWith( - 'late-snapshot-state\r\n', - expect.any(Function) - ) - disposable.dispose() - }) - - // STA-4042: the hidden-delivery gate drops renderer-bound bytes, so the span it - // ate can contain the `ESC[22m` closing a bold run. Abandoning the restore means - // no snapshot will rebuild the buffer, so unless the pen is cleared here every - // drained and subsequent cell inherits bold — the "regular text renders bold" - // field report. - it('clears the SGR pen before draining abandoned foreground chunks', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() - getMainBufferSnapshot.mockReturnValue(snapshot.promise) - // The dropped span is where `ESC[22m` would have been; the pen is left bold. - const hidden = '\x1b[1mbold-run-opened-while-hidden\r\n' - const live = 'live-after-reveal\r\n' - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - vi.useFakeTimers() - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(4) - - // Let the foreground deadline expire so the restore is abandoned. - vi.advanceTimersByTime(750) - vi.advanceTimersByTime(0) - await flushAsyncTicks(10) - - const written = pane.terminal.write.mock.calls.map(([data]) => data as string) - const resetIndex = written.indexOf(ABANDONED_RESTORE_GAP) - const liveIndex = written.findIndex((data) => data.includes('live-after-reveal')) - expect(resetIndex).toBeGreaterThanOrEqual(0) - expect(liveIndex).toBeGreaterThanOrEqual(0) - expect(resetIndex).toBeLessThan(liveIndex) - disposable.dispose() - }) - - it('grounds byte-gap state before a remote restore re-arms', async () => { - const { connectPanePty } = await import('./pty-connection') - const remotePtyId = 'remote:env-1@@terminal-rearm' - const transport = createMockTransport(remotePtyId) - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return remotePtyId - }) - const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() - transport.serializeBuffer = vi.fn().mockReturnValue(snapshot.promise) - transportFactoryQueue.push(transport) - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const live = 'remote-live-after-rearm\r\n' - - const pane = createPane(1) - const deps = createDeps({ isVisibleRef: { current: false } }) - const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(6) - - vi.useFakeTimers() - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(4) - - vi.advanceTimersByTime(750) - await flushAsyncTicks(10) - - const written = pane.terminal.write.mock.calls.map(([data]) => data as string) - const resetIndex = written.indexOf(ABANDONED_RESTORE_GAP) - const liveIndex = written.indexOf(live) - expect(resetIndex).toBeGreaterThanOrEqual(0) - expect(liveIndex).toBeGreaterThan(resetIndex) - // The re-arm arm grounds in rearmRemoteHiddenOutputRestoreInsteadOfWarning, - // so the abandon body must not ground a second time. - expect(written.filter((data) => data === ABANDONED_RESTORE_GAP)).toHaveLength(1) - expect(written.join('')).not.toContain('main recovery was unavailable') - - disposable.dispose() - }) - - it('falls back after repeated null hidden restore retries and drains blocked foreground', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue(null) - const hidden = 'hidden-codex-output\r\n' - const live = 'visible-after-null-retries\r\n' - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(10) - - for (let attempt = 0; attempt < 3; attempt++) { - expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) - vi.advanceTimersByTime(50) - vi.advanceTimersByTime(0) - await flushAsyncTicks(10) - } - - const written = pane.terminal.write.mock.calls.map(([data]) => data as string) - const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable')) - const liveIndex = written.indexOf(live) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(4) - expect(warningIndex).toBeGreaterThanOrEqual(0) - expect(liveIndex).toBeGreaterThan(warningIndex) - // This arm gives up on recovery too, so the gap is grounded exactly once - // before the blocked foreground is drained under it. - expect(written.filter((data) => data === ABANDONED_RESTORE_GAP)).toHaveLength(1) - expect(written.indexOf(ABANDONED_RESTORE_GAP)).toBeLessThan(liveIndex) - disposable.dispose() - }) - - it('drops pending foreground overflow when a stalled hidden restore falls back', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockReturnValue( - createDeferred<{ data: string; cols: number; rows: number; seq: number }>().promise - ) - const hidden = 'hidden-codex-output\r\n' - const liveOverflow = 'v'.repeat(512 * 1024 + 1) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(liveOverflow, { - seq: hidden.length + liveOverflow.length, - rawLength: liveOverflow.length - }) - await flushAsyncTicks(4) - - await vi.advanceTimersByTimeAsync(750) - await flushAsyncTicks(20) - - expect(pane.terminal.write).toHaveBeenCalledWith( - expect.stringContaining('main recovery was unavailable'), - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith(liveOverflow, expect.any(Function)) - disposable.dispose() - }) - - it('coalesces tiny pending foreground chunks when stalled hidden restore falls back', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockReturnValue( - createDeferred<{ data: string; cols: number; rows: number; seq: number }>().promise - ) - const hidden = 'hidden-codex-output\r\n' - const chunkCount = 2_000 - const liveChunk = 'x' - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - for (let index = 0; index < chunkCount; index += 1) { - capturedDataCallback.current?.(liveChunk, { - seq: hidden.length + index + 1, - rawLength: liveChunk.length - }) - } - await flushAsyncTicks(4) - - vi.advanceTimersByTime(750) - vi.advanceTimersByTime(0) - await flushAsyncTicks(10) - - const written = pane.terminal.write.mock.calls.map(([data]) => data as string) - const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable')) - const combinedLive = liveChunk.repeat(chunkCount) - const liveWrites = written.filter((data) => data === combinedLive) - expect(warningIndex).toBeGreaterThanOrEqual(0) - expect(liveWrites).toHaveLength(1) - expect(written.indexOf(combinedLive)).toBeGreaterThan(warningIndex) - - disposable.dispose() - expect(vi.getTimerCount()).toBe(0) - }) - - it('keeps foreground output when hidden-backlog snapshot recovery is unavailable', async () => { - const pendingTimeouts: { - canceled: boolean - delay: number - fn: () => void - id: number - }[] = [] - const originalSetTimeout = globalThis.setTimeout - const originalClearTimeout = globalThis.clearTimeout - let nextTimeoutId = 1 - globalThis.setTimeout = vi.fn((fn: () => void, delay?: number) => { - const timeout = { - canceled: false, - delay: typeof delay === 'number' ? delay : 0, - fn, - id: nextTimeoutId++ - } - pendingTimeouts.push(timeout) - return timeout.id as unknown as ReturnType - }) as unknown as typeof setTimeout - globalThis.clearTimeout = vi.fn((id: ReturnType) => { - const numericId = id as unknown as number - const timeout = pendingTimeouts.find((candidate) => candidate.id === numericId) - if (timeout) { - timeout.canceled = true - } - }) as unknown as typeof clearTimeout - - const runNextTimeoutWithDelay = async (delay: number): Promise => { - const index = pendingTimeouts.findIndex( - (timeout) => !timeout.canceled && timeout.delay === delay - ) - expect(index).toBeGreaterThanOrEqual(0) - const [timeout] = pendingTimeouts.splice(index, 1) - timeout.fn() - await flushAsyncTicks(20) - } - const drainTimeoutsWithDelay = async (delay: number): Promise => { - while (pendingTimeouts.some((timeout) => !timeout.canceled && timeout.delay === delay)) { - await runNextTimeoutWithDelay(delay) - } - } - - let disposable: { dispose: () => void } | null = null - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - getMainBufferSnapshot.mockResolvedValue(null) - - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const live = 'foreground-after-unavailable\r\n' - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - expect.stringContaining(live), - expect.any(Function) - ) - - await runNextTimeoutWithDelay(50) - await runNextTimeoutWithDelay(50) - await runNextTimeoutWithDelay(50) - await drainTimeoutsWithDelay(0) - - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(4) - expect(pane.terminal.write).toHaveBeenCalledWith( - expect.stringContaining( - 'Orca skipped hidden terminal output because main recovery was unavailable.' - ), - expect.any(Function) - ) - expect(pane.terminal.write).toHaveBeenCalledWith( - expect.stringContaining(live), - expect.any(Function) - ) - } finally { - disposable?.dispose() - globalThis.setTimeout = originalSetTimeout - globalThis.clearTimeout = originalClearTimeout - } - }) - - it('keeps a newer same-PTY hidden restore after a timed-out snapshot resolves late', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const firstSnapshot = createDeferred<{ - data: string - cols: number - rows: number - seq: number - }>() - const secondSnapshot = createDeferred<{ - data: string - cols: number - rows: number - seq: number - }>() - getMainBufferSnapshot - .mockReturnValueOnce(firstSnapshot.promise) - .mockReturnValueOnce(secondSnapshot.promise) - const firstHidden = 'first-hidden-output\r\n' - const firstLive = 'first-live-output\r\n' - const secondHidden = 'second-hidden-output\r\n' - const secondLive = 'second-live-output\r\n' - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - capturedDataCallback.current?.(firstHidden, { - seq: firstHidden.length, - rawLength: firstHidden.length - }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(firstLive, { - seq: firstHidden.length + firstLive.length, - rawLength: firstLive.length - }) - await flushAsyncTicks(4) - vi.advanceTimersByTime(750) - vi.advanceTimersByTime(0) - await flushAsyncTicks(10) - - pane.terminal.write.mockClear() - ;(deps.isVisibleRef as { current: boolean }).current = false - capturedDataCallback.current?.(secondHidden, { - seq: firstHidden.length + firstLive.length + secondHidden.length, - rawLength: secondHidden.length - }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(secondLive, { - seq: firstHidden.length + firstLive.length + secondHidden.length + secondLive.length, - rawLength: secondLive.length - }) - await flushAsyncTicks(4) - expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) - - firstSnapshot.resolve({ - data: 'stale-first-snapshot\r\n', - cols: 100, - rows: 30, - seq: firstHidden.length + firstLive.length - }) - await flushAsyncTicks(10) - secondSnapshot.resolve({ - data: 'fresh-second-snapshot\r\n', - cols: 100, - rows: 30, - seq: firstHidden.length + firstLive.length + secondHidden.length + secondLive.length - }) - await flushAsyncTicks(20) - - expect(pane.terminal.write).not.toHaveBeenCalledWith( - 'stale-first-snapshot\r\n', - expect.any(Function) - ) - expect(pane.terminal.write).toHaveBeenCalledWith( - 'fresh-second-snapshot\r\n', - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith(secondLive, expect.any(Function)) - disposable.dispose() - }) - - it('ignores an async hidden-backlog snapshot if the pane changes PTYs first', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('old-pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'old-pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() - getMainBufferSnapshot.mockReturnValue(snapshot.promise) - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const live = 'old-live-output\r\n' - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(2) - transport.getPtyId.mockReturnValue('new-pty-id') - snapshot.resolve({ - data: 'old-snapshot-state\r\n', - cols: 100, - rows: 30, - seq: hidden.length + live.length - }) - await flushAsyncTicks(20) - - expect(getMainBufferSnapshot).toHaveBeenCalledWith('old-pty-id', { scrollbackRows: 5000 }) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - 'old-snapshot-state\r\n', - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) - disposable.dispose() - }) - - it('does not recover stale hidden backlog state after the pane switches PTYs', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('old-pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'old-pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const newPtyOutput = 'new-pty-output\r\n' - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - transport.getPtyId.mockReturnValue('new-pty-id') - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(newPtyOutput, { - seq: newPtyOutput.length, - rawLength: newPtyOutput.length - }) - await flushAsyncTicks(10) - - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(newPtyOutput, expect.any(Function)) - disposable.dispose() - }) - - it('does not replay pending hidden restore chunks after a terminal clear', async () => { - const clearBufferCallback: { - current: ((request: { ptyId: string }) => void) | null - } = { current: null } - window.api.pty.onClearBufferRequest = vi.fn((callback) => { - clearBufferCallback.current = callback as (request: { ptyId: string }) => void - return vi.fn() - }) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< - typeof vi.fn - > - const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() - getMainBufferSnapshot.mockReturnValue(snapshot.promise) - const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) - const live = 'pre-clear-live-output\r\n' - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - const disposable = connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - capturedDataCallback.current?.(live, { - seq: hidden.length + live.length, - rawLength: live.length - }) - await flushAsyncTicks(2) - expect(clearBufferCallback.current).not.toBeNull() - - clearBufferCallback.current?.({ ptyId: 'pty-id' }) - snapshot.resolve({ - data: '', - cols: 120, - rows: 40, - seq: hidden.length + live.length - }) - await flushAsyncTicks(20) - - expect(pane.terminal.clear).toHaveBeenCalled() - expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - NORMAL_BUFFER_PROLOGUE, - expect.any(Function) - ) - disposable.dispose() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.spec.ts new file mode 100644 index 00000000000..60bf7f1faa7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.spec.ts @@ -0,0 +1,509 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-limits' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { flushAsyncTicks, drainPendingTimeouts } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionStartupCommandDeliveryTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('does not send startup command via sendInput for local connections', async () => { + // Why: the local PTY provider already writes the command via writeStartupCommandWhenShellReady; re-sending from the renderer would double it in the terminal. + const { connectPanePty } = await import('./pty-connection') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport() + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-local-1' + } + ) + transportFactoryQueue.push(transport) + + // Local connection: no connectionId + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: null }] + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ startup: { command: "claude 'say test'" } }) + + connectPanePty(pane as never, manager as never, deps as never) + expect(capturedDataCallback.current).not.toBeNull() + + // Simulate PTY output (shell prompt arriving) + capturedDataCallback.current?.('(base) user@host $ ') + + // Even after the debounce, the renderer must not inject the command (main already wrote it via writeStartupCommandWhenShellReady). + expect(transport.sendInput).not.toHaveBeenCalledWith( + expect.stringContaining("claude 'say test'") + ) + }) + + it('seeds a working status for Command Code startup prompts after spawn', async () => { + const { connectPanePty } = await import('./pty-connection') + const ptyId = 'pty-command-code' + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] } + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: "command-code --trust 'Fix the status'", + initialAgentStatus: { agent: 'command-code', prompt: 'Fix the status' } + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + expect(onPtySpawn).toBeTypeOf('function') + onPtySpawn?.(ptyId) + + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + makePaneKey('tab-1', LEAF_1), + { + state: 'working', + prompt: 'Fix the status', + agentType: 'command-code', + // Why: Orca launched this agent, so the seed predates any provider signal (STA-4293). + observation: expect.objectContaining({ origin: 'launch', kind: 'transition' }) + }, + undefined, + undefined, + { connectionId: null } + ) + }) + + it('seeds a working status from Command Code thinking output without a startup prompt', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-command-code' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return ptyId + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: 'command-code --trust' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('# Command Code v0.27.2\r\n') + capturedDataCallback.current?.('❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') + + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + makePaneKey('tab-1', LEAF_1), + { + state: 'working', + prompt: 'Fix the spinner', + agentType: 'command-code', + // Why: read off the pane's own output, not a provider hook (STA-4293). + observation: expect.objectContaining({ origin: 'process', kind: 'transition' }) + }, + undefined, + undefined, + { connectionId: null } + ) + }) + + it('ignores delayed Command Code output after the leaf rebinds to another SSH host', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const oldPtyId = toAppSshPtyId('ssh-a', 'pty-command-code') + const newPtyId = toAppSshPtyId('ssh-b', 'pty-command-code') + const transport = createMockTransport(oldPtyId) + transport.getConnectionId.mockReturnValue('ssh-a') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return oldPtyId + } + ) + transportFactoryQueue.push(transport) + mockStoreState.repos = [{ id: 'repo1', connectionId: 'ssh-a' }] + mockStoreState.sshConnectionStates = new Map([['ssh-a', { status: 'connected' }]]) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup: { command: 'command-code --trust' } }) as never + ) + await flushAsyncTicks() + mockStoreState.setAgentStatus.mockClear() + mockStoreState.ptyIdsByTabId = { 'tab-1': [oldPtyId, newPtyId] } + mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { [LEAF_1]: newPtyId } + + capturedDataCallback.current?.('❯ stale prompt\r\n\x1b[35m✻ Thinking...\x1b[0m') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + }) + + it('marks a Command Code no-tool turn done after the idle prompt settles', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-command-code') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-command-code' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: 'command-code --trust' + } + }) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(pane as never, manager as never, deps as never) + vi.runOnlyPendingTimers() + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('❯ say hi\r\n✻ Thinking...') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'say hi', + agentType: 'command-code' + }) + + capturedDataCallback.current?.( + '\r\n✻ Thought for 1 second\r\n:: Hi! How can I help you today?\r\n❯ Ask your question...' + ) + vi.advanceTimersByTime(1499) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'say hi', + agentType: 'command-code' + }) + + vi.advanceTimersByTime(1) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + prompt: 'say hi', + agentType: 'command-code' + }) + }) + + it('keeps Command Code working when an active repaint follows the idle prompt before settle', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-command-code') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-command-code' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: 'command-code --trust' + } + }) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(pane as never, manager as never, deps as never) + vi.runOnlyPendingTimers() + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('❯ Run a slow command\r\n✻ Thinking...') + capturedDataCallback.current?.('\r\n❯ Ask your question...') + vi.advanceTimersByTime(1000) + capturedDataCallback.current?.('\r\n✧ Investigating... esc to interrupt') + vi.advanceTimersByTime(500) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'Run a slow command', + agentType: 'command-code' + }) + }) + + it('does not downgrade a completed Command Code turn back to working from stale TUI output', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-command-code') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-command-code' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: 'command-code --trust' + } + }) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + paneKey, + state: 'done', + prompt: 'Fix the spinner', + agentType: 'command-code', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('# Command Code v0.27.2\r\n') + capturedDataCallback.current?.('❯ Fix the spinner\r\n\x1b[35m✻ Threading...\x1b[0m') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + expect((mockStoreState.agentStatusByPaneKey[paneKey] as { state?: unknown })?.state).toBe( + 'done' + ) + }) + + it('starts a new Command Code turn after done when TUI output carries a different prompt', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-command-code') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-command-code' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: 'command-code --trust' + } + }) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + paneKey, + state: 'done', + prompt: 'Fix the spinner', + agentType: 'command-code', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('# Command Code v0.27.2\r\n') + capturedDataCallback.current?.('❯ Fix the green done state\r\n\x1b[35m✻ Threading...\x1b[0m') + + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + paneKey, + { + state: 'working', + prompt: 'Fix the green done state', + agentType: 'command-code', + observation: expect.objectContaining({ origin: 'process', kind: 'transition' }) + }, + undefined, + undefined, + { connectionId: null } + ) + }) + + it('chunks large terminal-paste startup commands through the PTY before submitting', async () => { + const pendingTimeouts: (() => void)[] = [] + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = vi.fn((fn: () => void) => { + pendingTimeouts.push(fn) + return 999 as unknown as ReturnType + }) as unknown as typeof setTimeout + + try { + const { connectPanePty } = await import('./pty-connection') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-id') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-local-paste' + } + ) + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: null }] + } + + const pane = createPane(1) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const command = `${'x'.repeat(TERMINAL_PASTE_DIRECT_MAX_BYTES)}tail` + const deps = createDeps({ startup: { command, delivery: 'terminal-paste' } }) + + connectPanePty(pane as never, manager as never, deps as never) + expect(createdTransportOptions[0]?.command).toBeUndefined() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('user@host $ ') + await drainPendingTimeouts(pendingTimeouts) + await flushAsyncTicks() + + const writtenInput = transport.sendInput.mock.calls.map((call) => call[0]) + expect(pane.terminal.paste).not.toHaveBeenCalled() + expect(writtenInput.at(-1)).toBe('\r') + expect(writtenInput.slice(0, -1).join('')).toBe(command) + expect(writtenInput.slice(0, -1).length).toBeGreaterThan(1) + expect(transport.sendInput).not.toHaveBeenCalledWith(`${command}\r`) + } finally { + globalThis.setTimeout = originalSetTimeout + } + }) + + it('does not submit a terminal-paste startup command after the PTY changes mid-paste', async () => { + const pendingTimeouts: (() => void)[] = [] + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = vi.fn((fn: () => void) => { + pendingTimeouts.push(fn) + return 999 as unknown as ReturnType + }) as unknown as typeof setTimeout + + try { + const { connectPanePty } = await import('./pty-connection') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-id') + let livePtyId = 'pty-local-paste' + transport.getPtyId.mockImplementation(() => livePtyId) + transport.sendInput.mockImplementation((data: string) => { + if (data !== '\r') { + livePtyId = 'pty-replaced' + } + return true + }) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-local-paste' + } + ) + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: null }] + } + + const pane = createPane(1) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const command = `${'x'.repeat(TERMINAL_PASTE_DIRECT_MAX_BYTES)}tail` + const deps = createDeps({ startup: { command, delivery: 'terminal-paste' } }) + + connectPanePty(pane as never, manager as never, deps as never) + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('user@host $ ') + await drainPendingTimeouts(pendingTimeouts) + await flushAsyncTicks() + + expect(pane.terminal.paste).not.toHaveBeenCalled() + expect(transport.sendInput).not.toHaveBeenCalledWith('\r') + expect(transport.sendInput.mock.calls.map((call) => call[0]).join('')).not.toBe(command) + } finally { + globalThis.setTimeout = originalSetTimeout + } + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts deleted file mode 100644 index 65c1bfe9863..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts +++ /dev/null @@ -1,586 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-limits' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' -import { flushAsyncTicks, drainPendingTimeouts } from './pty-connection-test-async' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('does not send startup command via sendInput for local connections', async () => { - // Why: the local PTY provider already writes the command via writeStartupCommandWhenShellReady; re-sending from the renderer would double it in the terminal. - const { connectPanePty } = await import('./pty-connection') - - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport() - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-local-1' - }) - transportFactoryQueue.push(transport) - - // Local connection: no connectionId - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: null }] - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ startup: { command: "claude 'say test'" } }) - - connectPanePty(pane as never, manager as never, deps as never) - expect(capturedDataCallback.current).not.toBeNull() - - // Simulate PTY output (shell prompt arriving) - capturedDataCallback.current?.('(base) user@host $ ') - - // Even after the debounce, the renderer must not inject the command (main already wrote it via writeStartupCommandWhenShellReady). - expect(transport.sendInput).not.toHaveBeenCalledWith( - expect.stringContaining("claude 'say test'") - ) - }) - - it('seeds a working status for Command Code startup prompts after spawn', async () => { - const { connectPanePty } = await import('./pty-connection') - const ptyId = 'pty-command-code' - const transport = createMockTransport(ptyId) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] } - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - startup: { - command: "command-code --trust 'Fix the status'", - initialAgentStatus: { agent: 'command-code', prompt: 'Fix the status' } - } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as - | ((ptyId: string) => void) - | undefined - expect(onPtySpawn).toBeTypeOf('function') - onPtySpawn?.(ptyId) - - expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( - makePaneKey('tab-1', LEAF_1), - { - state: 'working', - prompt: 'Fix the status', - agentType: 'command-code', - // Why: Orca launched this agent, so the seed predates any provider signal (STA-4293). - observation: expect.objectContaining({ origin: 'launch', kind: 'transition' }) - }, - undefined, - undefined, - { connectionId: null } - ) - }) - - it('seeds a working status from Command Code thinking output without a startup prompt', async () => { - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'pty-command-code' - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return ptyId - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - startup: { - command: 'command-code --trust' - } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('# Command Code v0.27.2\r\n') - capturedDataCallback.current?.('❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') - - expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( - makePaneKey('tab-1', LEAF_1), - { - state: 'working', - prompt: 'Fix the spinner', - agentType: 'command-code', - // Why: read off the pane's own output, not a provider hook (STA-4293). - observation: expect.objectContaining({ origin: 'process', kind: 'transition' }) - }, - undefined, - undefined, - { connectionId: null } - ) - }) - - it('ignores delayed Command Code output after the leaf rebinds to another SSH host', async () => { - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const oldPtyId = toAppSshPtyId('ssh-a', 'pty-command-code') - const newPtyId = toAppSshPtyId('ssh-b', 'pty-command-code') - const transport = createMockTransport(oldPtyId) - transport.getConnectionId.mockReturnValue('ssh-a') - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return oldPtyId - }) - transportFactoryQueue.push(transport) - mockStoreState.repos = [{ id: 'repo1', connectionId: 'ssh-a' }] - mockStoreState.sshConnectionStates = new Map([['ssh-a', { status: 'connected' }]]) - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ startup: { command: 'command-code --trust' } }) as never - ) - await flushAsyncTicks() - mockStoreState.setAgentStatus.mockClear() - mockStoreState.ptyIdsByTabId = { 'tab-1': [oldPtyId, newPtyId] } - mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { [LEAF_1]: newPtyId } - - capturedDataCallback.current?.('❯ stale prompt\r\n\x1b[35m✻ Thinking...\x1b[0m') - - expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() - }) - - it('marks a Command Code no-tool turn done after the idle prompt settles', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-command-code') - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-command-code' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - startup: { - command: 'command-code --trust' - } - }) - const paneKey = makePaneKey('tab-1', LEAF_1) - - connectPanePty(pane as never, manager as never, deps as never) - vi.runOnlyPendingTimers() - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('❯ say hi\r\n✻ Thinking...') - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'working', - prompt: 'say hi', - agentType: 'command-code' - }) - - capturedDataCallback.current?.( - '\r\n✻ Thought for 1 second\r\n:: Hi! How can I help you today?\r\n❯ Ask your question...' - ) - vi.advanceTimersByTime(1499) - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'working', - prompt: 'say hi', - agentType: 'command-code' - }) - - vi.advanceTimersByTime(1) - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - prompt: 'say hi', - agentType: 'command-code' - }) - }) - - it('keeps Command Code working when an active repaint follows the idle prompt before settle', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-command-code') - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-command-code' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - startup: { - command: 'command-code --trust' - } - }) - const paneKey = makePaneKey('tab-1', LEAF_1) - - connectPanePty(pane as never, manager as never, deps as never) - vi.runOnlyPendingTimers() - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('❯ Run a slow command\r\n✻ Thinking...') - capturedDataCallback.current?.('\r\n❯ Ask your question...') - vi.advanceTimersByTime(1000) - capturedDataCallback.current?.('\r\n✧ Investigating... esc to interrupt') - vi.advanceTimersByTime(500) - - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'working', - prompt: 'Run a slow command', - agentType: 'command-code' - }) - }) - - it('does not downgrade a completed Command Code turn back to working from stale TUI output', async () => { - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-command-code') - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-command-code' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - startup: { - command: 'command-code --trust' - } - }) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - paneKey, - state: 'done', - prompt: 'Fix the spinner', - agentType: 'command-code', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - stateHistory: [] - } - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('# Command Code v0.27.2\r\n') - capturedDataCallback.current?.('❯ Fix the spinner\r\n\x1b[35m✻ Threading...\x1b[0m') - - expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() - expect((mockStoreState.agentStatusByPaneKey[paneKey] as { state?: unknown })?.state).toBe( - 'done' - ) - }) - - it('starts a new Command Code turn after done when TUI output carries a different prompt', async () => { - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-command-code') - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-command-code' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - startup: { - command: 'command-code --trust' - } - }) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - paneKey, - state: 'done', - prompt: 'Fix the spinner', - agentType: 'command-code', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - stateHistory: [] - } - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('# Command Code v0.27.2\r\n') - capturedDataCallback.current?.('❯ Fix the green done state\r\n\x1b[35m✻ Threading...\x1b[0m') - - expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( - paneKey, - { - state: 'working', - prompt: 'Fix the green done state', - agentType: 'command-code', - observation: expect.objectContaining({ origin: 'process', kind: 'transition' }) - }, - undefined, - undefined, - { connectionId: null } - ) - }) - - it('chunks large terminal-paste startup commands through the PTY before submitting', async () => { - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout - - try { - const { connectPanePty } = await import('./pty-connection') - - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-id') - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-local-paste' - } - ) - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: null }] - } - - const pane = createPane(1) - pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { - callback?.() - }) - const manager = createManager(1) - const command = `${'x'.repeat(TERMINAL_PASTE_DIRECT_MAX_BYTES)}tail` - const deps = createDeps({ startup: { command, delivery: 'terminal-paste' } }) - - connectPanePty(pane as never, manager as never, deps as never) - expect(createdTransportOptions[0]?.command).toBeUndefined() - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('user@host $ ') - await drainPendingTimeouts(pendingTimeouts) - await flushAsyncTicks() - - const writtenInput = transport.sendInput.mock.calls.map((call) => call[0]) - expect(pane.terminal.paste).not.toHaveBeenCalled() - expect(writtenInput.at(-1)).toBe('\r') - expect(writtenInput.slice(0, -1).join('')).toBe(command) - expect(writtenInput.slice(0, -1).length).toBeGreaterThan(1) - expect(transport.sendInput).not.toHaveBeenCalledWith(`${command}\r`) - } finally { - globalThis.setTimeout = originalSetTimeout - } - }) - - it('does not submit a terminal-paste startup command after the PTY changes mid-paste', async () => { - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout - - try { - const { connectPanePty } = await import('./pty-connection') - - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-id') - let livePtyId = 'pty-local-paste' - transport.getPtyId.mockImplementation(() => livePtyId) - transport.sendInput.mockImplementation((data: string) => { - if (data !== '\r') { - livePtyId = 'pty-replaced' - } - return true - }) - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-local-paste' - } - ) - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: null }] - } - - const pane = createPane(1) - pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { - callback?.() - }) - const manager = createManager(1) - const command = `${'x'.repeat(TERMINAL_PASTE_DIRECT_MAX_BYTES)}tail` - const deps = createDeps({ startup: { command, delivery: 'terminal-paste' } }) - - connectPanePty(pane as never, manager as never, deps as never) - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('user@host $ ') - await drainPendingTimeouts(pendingTimeouts) - await flushAsyncTicks() - - expect(pane.terminal.paste).not.toHaveBeenCalled() - expect(transport.sendInput).not.toHaveBeenCalledWith('\r') - expect(transport.sendInput.mock.calls.map((call) => call[0]).join('')).not.toBe(command) - } finally { - globalThis.setTimeout = originalSetTimeout - } - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-startup-draft-delivery.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-startup-draft-delivery.spec.ts new file mode 100644 index 00000000000..74b265e89c7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-startup-draft-delivery.spec.ts @@ -0,0 +1,254 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { beginAgentStartupDeliveryAttempt } from '@/lib/agent-startup-delayed-delivery' +import { flushAsyncTicks } from './pty-connection-test-async' +import { VISIBLE_PTY_SETTLE_MS } from './pty-connection-test-constants' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionStartupDraftDeliveryTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it.each([10_000, 20_000])('keeps Codex draft delivery bounded after %d ms', async (waitMs) => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const { beginAgentStartupDeliveryAttempt: claimStartupDelivery } = + await import('@/lib/agent-startup-delayed-delivery') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-codex') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-codex' + } + ) + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: null }] + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: 'codex', + launchAgent: 'codex', + launchConfig: { agentArgs: '', agentEnv: {} }, + launchToken: 'launch-token-1', + draftPrompt: 'https://github.com/stablyai/orca/issues/42' + } + }) + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex') + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the connection fields exercised here without a real terminal or DOM. + const binding = connectPanePty(pane as never, manager as never, deps as never) + await vi.advanceTimersByTimeAsync(VISIBLE_PTY_SETTLE_MS) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + // A focused xterm emits CSI I after Codex enables focus reporting; the startup draft must reuse the same transport instead of racing a direct IPC. + ;( + pane.terminal.onData as unknown as { + mock: { calls: [(data: string) => void][] } + } + ).mock.calls[0]?.[0]('\x1b[I') + ;( + pane.terminal.onData as unknown as { + mock: { calls: [(data: string) => void][] } + } + ).mock.calls[0]?.[0]('USER_DRAFT') + ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() + await vi.advanceTimersByTimeAsync(waitMs) + expect(transport.sendInputAccepted).not.toHaveBeenCalled() + capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') + await flushAsyncTicks() + + if (waitMs === 20_000) { + expect(transport.sendInputAccepted).not.toHaveBeenCalled() + expect(transport.sendInput.mock.calls.map(([data]) => data)).toEqual([ + '\x1b[I', + 'USER_DRAFT' + ]) + binding.dispose() + expect( + claimStartupDelivery({ + worktreeId: 'wt-1', + tabId: 'tab-1', + launchToken: 'launch-token-1' + }) + ).toBe(false) + return + } + + expect(transport.sendInputAccepted).toHaveBeenCalledWith( + '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~', + 'launch' + ) + expect(transport.sendInput.mock.calls.map(([data]) => data)).toEqual([ + '\x1b[I', + 'USER_DRAFT', + '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~' + ]) + expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() + expect(mockStoreState.recordTerminalInput).toHaveBeenCalledOnce() + expect(mockStoreState.recordTerminalInput).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1)) + }) + + it('keeps startup draft ownership while deferred connect waits through setup', async () => { + const { connectPanePty } = await import('./pty-connection') + const { beginAgentStartupDeliveryAttempt: claimStartupDelivery } = + await import('@/lib/agent-startup-delayed-delivery') + + const deferredFrames: FrameRequestCallback[] = [] + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + deferredFrames.push(callback) + return 1 + }) + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-codex') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-codex' + } + ) + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: null }] + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: 'wait-for-setup-then-codex', + launchAgent: 'codex', + launchConfig: { agentArgs: '', agentEnv: {} }, + launchToken: 'launch-token-setup', + draftPrompt: 'Linked Linear issue: STA-905' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + const sidecarClaimed = claimStartupDelivery({ + worktreeId: 'wt-1', + tabId: 'tab-1', + launchToken: 'launch-token-setup' + }) + expect(sidecarClaimed).toBe(false) + + let frameCount = 0 + while (deferredFrames.length > 0 && transport.connect.mock.calls.length === 0) { + if (frameCount >= 20) { + throw new Error('startup did not connect after the deferred setup handoff') + } + frameCount += 1 + deferredFrames.shift()?.(frameCount * 16) + } + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + capturedDataCallback.current?.('\x1b[?2004hWaiting for setup to finish...') + expect(transport.sendInputAccepted).not.toHaveBeenCalled() + + capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') + await flushAsyncTicks() + + expect(transport.sendInputAccepted).toHaveBeenCalledTimes(1) + expect(transport.sendInputAccepted).toHaveBeenCalledWith( + '\x1b[200~Linked Linear issue: STA-905\x1b[201~', + 'launch' + ) + }) + + it('releases startup draft delivery when disposed before deferred connect starts', async () => { + const { connectPanePty } = await import('./pty-connection') + globalThis.requestAnimationFrame = vi.fn(() => 1) + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: null }] + } + + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + startup: { + command: 'codex', + launchAgent: 'codex', + launchConfig: { agentArgs: '', agentEnv: {} }, + launchToken: 'launch-token-1', + draftPrompt: 'https://github.com/stablyai/orca/issues/42' + } + }) as never + ) + + binding.dispose() + + expect(transport.connect).not.toHaveBeenCalled() + expect( + beginAgentStartupDeliveryAttempt({ + worktreeId: 'wt-1', + tabId: 'tab-1', + launchToken: 'launch-token-1' + }) + ).toBe(true) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-startup-draft-delivery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-startup-draft-delivery.test.ts deleted file mode 100644 index 6111ab4335c..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-startup-draft-delivery.test.ts +++ /dev/null @@ -1,338 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { beginAgentStartupDeliveryAttempt } from '@/lib/agent-startup-delayed-delivery' -import { flushAsyncTicks } from './pty-connection-test-async' -import { VISIBLE_PTY_SETTLE_MS } from './pty-connection-test-constants' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it.each([10_000, 20_000])('keeps Codex draft delivery bounded after %d ms', async (waitMs) => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const { beginAgentStartupDeliveryAttempt: claimStartupDelivery } = - await import('@/lib/agent-startup-delayed-delivery') - - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-codex') - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-codex' - }) - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: null }] - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - startup: { - command: 'codex', - launchAgent: 'codex', - launchConfig: { agentArgs: '', agentEnv: {} }, - launchToken: 'launch-token-1', - draftPrompt: 'https://github.com/stablyai/orca/issues/42' - } - }) - vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex') - - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the connection fields exercised here without a real terminal or DOM. - const binding = connectPanePty(pane as never, manager as never, deps as never) - await vi.advanceTimersByTimeAsync(VISIBLE_PTY_SETTLE_MS) - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - - // A focused xterm emits CSI I after Codex enables focus reporting; the startup draft must reuse the same transport instead of racing a direct IPC. - ;( - pane.terminal.onData as unknown as { - mock: { calls: [(data: string) => void][] } - } - ).mock.calls[0]?.[0]('\x1b[I') - ;( - pane.terminal.onData as unknown as { - mock: { calls: [(data: string) => void][] } - } - ).mock.calls[0]?.[0]('USER_DRAFT') - ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() - await vi.advanceTimersByTimeAsync(waitMs) - expect(transport.sendInputAccepted).not.toHaveBeenCalled() - capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') - await flushAsyncTicks() - - if (waitMs === 20_000) { - expect(transport.sendInputAccepted).not.toHaveBeenCalled() - expect(transport.sendInput.mock.calls.map(([data]) => data)).toEqual(['\x1b[I', 'USER_DRAFT']) - binding.dispose() - expect( - claimStartupDelivery({ - worktreeId: 'wt-1', - tabId: 'tab-1', - launchToken: 'launch-token-1' - }) - ).toBe(false) - return - } - - expect(transport.sendInputAccepted).toHaveBeenCalledWith( - '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~', - 'launch' - ) - expect(transport.sendInput.mock.calls.map(([data]) => data)).toEqual([ - '\x1b[I', - 'USER_DRAFT', - '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~' - ]) - expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() - expect(mockStoreState.recordTerminalInput).toHaveBeenCalledOnce() - expect(mockStoreState.recordTerminalInput).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1)) - }) - - it('keeps startup draft ownership while deferred connect waits through setup', async () => { - const { connectPanePty } = await import('./pty-connection') - const { beginAgentStartupDeliveryAttempt: claimStartupDelivery } = - await import('@/lib/agent-startup-delayed-delivery') - - const deferredFrames: FrameRequestCallback[] = [] - globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - deferredFrames.push(callback) - return 1 - }) - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('pty-codex') - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-codex' - }) - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: null }] - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - startup: { - command: 'wait-for-setup-then-codex', - launchAgent: 'codex', - launchConfig: { agentArgs: '', agentEnv: {} }, - launchToken: 'launch-token-setup', - draftPrompt: 'Linked Linear issue: STA-905' - } - }) - - connectPanePty(pane as never, manager as never, deps as never) - - const sidecarClaimed = claimStartupDelivery({ - worktreeId: 'wt-1', - tabId: 'tab-1', - launchToken: 'launch-token-setup' - }) - expect(sidecarClaimed).toBe(false) - - let frameCount = 0 - while (deferredFrames.length > 0 && transport.connect.mock.calls.length === 0) { - if (frameCount >= 20) { - throw new Error('startup did not connect after the deferred setup handoff') - } - frameCount += 1 - deferredFrames.shift()?.(frameCount * 16) - } - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - capturedDataCallback.current?.('\x1b[?2004hWaiting for setup to finish...') - expect(transport.sendInputAccepted).not.toHaveBeenCalled() - - capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') - await flushAsyncTicks() - - expect(transport.sendInputAccepted).toHaveBeenCalledTimes(1) - expect(transport.sendInputAccepted).toHaveBeenCalledWith( - '\x1b[200~Linked Linear issue: STA-905\x1b[201~', - 'launch' - ) - }) - - it('releases startup draft delivery when disposed before deferred connect starts', async () => { - const { connectPanePty } = await import('./pty-connection') - globalThis.requestAnimationFrame = vi.fn(() => 1) - const transport = createMockTransport('pty-codex') - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: null }] - } - - const binding = connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ - startup: { - command: 'codex', - launchAgent: 'codex', - launchConfig: { agentArgs: '', agentEnv: {} }, - launchToken: 'launch-token-1', - draftPrompt: 'https://github.com/stablyai/orca/issues/42' - } - }) as never - ) - - binding.dispose() - - expect(transport.connect).not.toHaveBeenCalled() - expect( - beginAgentStartupDeliveryAttempt({ - worktreeId: 'wt-1', - tabId: 'tab-1', - launchToken: 'launch-token-1' - }) - ).toBe(true) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.spec.ts new file mode 100644 index 00000000000..8a68c1d2187 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.spec.ts @@ -0,0 +1,643 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './pty-connection-test-constants' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import type * as UseNotificationDispatchModule from './use-notification-dispatch' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { + createInitialStoreState, + buildActiveRuntimeEnvironmentState +} from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionTaskCompleteDispatchTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + // Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. + function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { + mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) + } + + function notifyStoreSubscribers(): void { + for (const listener of storeSubscribers.slice()) { + listener(mockStoreState) + } + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('dispatches agent-task-complete for generic Codex spinner titles after process identity is confirmed', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const api = ( + globalThis as unknown as { + window: { api: { pty: { getForegroundProcess: ReturnType } } } + } + ).window.api + api.pty.getForegroundProcess.mockResolvedValue('codex') + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') + titleHandler('experimental-agent-observability', 'experimental-agent-observability') + await flushAsyncTicks() + + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith({ + source: 'agent-task-complete', + terminalTitle: 'experimental-agent-observability', + ptyId: 'tab-pty', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + expect(window.api.pty.inspectProcess).toHaveBeenCalledWith('pty-codex') + }) + + it('does not dispatch generic spinner completions when process inspection finds no agent', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-shell') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const api = ( + globalThis as unknown as { + window: { api: { pty: { getForegroundProcess: ReturnType } } } + } + ).window.api + api.pty.getForegroundProcess.mockResolvedValue('zsh') + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') + titleHandler('experimental-agent-observability', 'experimental-agent-observability') + await flushAsyncTicks() + + vi.advanceTimersByTime(16_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('dispatches agent-task-complete from recognized hook completion events', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + mockStoreState.ptyIdsByTabId = { 'tab-1': ['pty-hook'] } + mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { [LEAF_1]: 'pty-hook' } + + vi.useFakeTimers() + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { + state: 'done' + prompt: string + agentType: 'codex' + lastAssistantMessage: string + }) => void) + | undefined + if (!statusHandler) { + throw new Error('Expected onAgentStatus to be registered') + } + + statusHandler({ + state: 'done', + prompt: 'finish the implementation', + agentType: 'codex', + lastAssistantMessage: 'Done.' + }) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + ) + }) + + it('preserves synthetic Codex permission titles', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { + agentArgs: '', + agentEnv: {} + } + } + + const pane = createPane(1) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + const deps = createDeps({ + startup: { + command: 'codex', + launchConfig: { + agentCommand: 'codex', + agentArgs: '', + agentEnv: {} + }, + launchToken: 'launch-manual', + launchAgent: 'codex' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('Codex - action required', 'Codex - action required') + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex - action required') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Codex - action required') + }) + + it('normalizes Pi-compatible remote titles to authoritative OMP launch identity', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-omp') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', launchAgent: 'omp' }] + } + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: '\u280b Pi' } } + + const pane = createPane(1) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + titleHandler('\u280b Pi', '\u280b Pi') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, '\u280b OMP') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', '\u280b OMP') + titleHandler('π: tmp', 'π: tmp') + expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'OMP: tmp') + expect(deps.updateTabTitle).toHaveBeenLastCalledWith('tab-1', 'OMP: tmp') + + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'working'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!statusHandler) { + throw new Error('Expected onAgentStatus to be registered') + } + + statusHandler({ + state: 'working', + prompt: 'fix the remote title', + agentType: 'pi' + }) + + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + makePaneKey('tab-1', LEAF_1), + { + state: 'working', + prompt: 'fix the remote title', + agentType: 'omp', + observation: expect.objectContaining({ origin: 'osc', kind: 'snapshot' }) + }, + '\u280b OMP', + undefined, + { connectionId: null } + ) + + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] + } + statusHandler({ + state: 'working', + prompt: 'keep the remote title', + agentType: 'pi' + }) + + expect(mockStoreState.setAgentStatus).toHaveBeenLastCalledWith( + makePaneKey('tab-1', LEAF_1), + { + state: 'working', + prompt: 'keep the remote title', + agentType: 'omp', + observation: expect.objectContaining({ origin: 'osc', kind: 'snapshot' }) + }, + '\u280b OMP', + undefined, + { connectionId: null } + ) + }) + + it('keeps GPU rendering enabled for OMP titles whose cwd is Gemini', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-omp-gemini-cwd') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', launchAgent: 'omp' }] + } + + const pane = createPane(1) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + manager.setPaneGpuRendering.mockClear() + + titleHandler('\u280b Pi', '\u280b π: gemini') + + expect(manager.setPaneGpuRendering).toHaveBeenCalledTimes(1) + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, true) + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, '\u280b OMP') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', '\u280b OMP') + }) + + it('leaves local IPC OSC 9999 status ownership in the main runtime', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-local') + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(createdTransportOptions[0]?.onAgentStatus).toBeUndefined() + }) + + it('queues the idle cursor reset behind hidden agent output', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + vi.useFakeTimers() + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!capturedDataCallback.current || !idleHandler) { + throw new Error('Expected PTY data and idle handlers to be registered') + } + + capturedDataCallback.current('\x1b[6 q') + idleHandler('* Codex done') + + expect(pane.terminal.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(50) + expect(pane.terminal.write).toHaveBeenCalledWith(`\x1b[6 q${RESET_TERMINAL_CURSOR_STYLE}`) + }) + + it('waits briefly for delayed agent status before dispatching task-complete', async () => { + const { connectPanePty } = await import('./pty-connection') + const { useNotificationDispatch } = await vi.importActual< + typeof UseNotificationDispatchModule + >('./use-notification-dispatch') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const dispatchNotification = useNotificationDispatch('wt-1') + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ dispatchNotification }) + + connectPanePty(pane as never, manager as never, deps as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!idleHandler) { + throw new Error('Expected onAgentBecameIdle to be registered') + } + + idleHandler('* Codex done') + expect(window.api.notifications.dispatch).not.toHaveBeenCalled() + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'Use delayed hook status in notification', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'codex', + paneKey, + terminalTitle: '* Codex done', + stateHistory: [], + lastAssistantMessage: 'Delayed status arrived.' + } + + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + await flushAsyncTicks() + + expect(window.api.notifications.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent-task-complete', + terminalTitle: '* Codex done', + agentType: 'codex', + agentState: 'done', + agentPrompt: 'Use delayed hook status in notification', + agentLastAssistantMessage: 'Delayed status arrived.' + }) + ) + }) + + it('waits past the grace delay when the assistant message arrives shortly after it', async () => { + const { connectPanePty } = await import('./pty-connection') + const { useNotificationDispatch } = await vi.importActual< + typeof UseNotificationDispatchModule + >('./use-notification-dispatch') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const dispatchNotification = useNotificationDispatch('wt-1') + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ dispatchNotification }) + + connectPanePty(pane as never, manager as never, deps as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!idleHandler) { + throw new Error('Expected onAgentBecameIdle to be registered') + } + + idleHandler('* Codex done') + vi.advanceTimersByTime(250) + await flushAsyncTicks() + expect(window.api.notifications.dispatch).not.toHaveBeenCalled() + + vi.advanceTimersByTime(100) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'Use the late hook status in notification', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'codex', + paneKey, + terminalTitle: '* Codex done', + stateHistory: [], + lastAssistantMessage: 'Late status arrived.' + } + notifyStoreSubscribers() + await flushAsyncTicks() + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS - 350) + await flushAsyncTicks() + + expect(window.api.notifications.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent-task-complete', + terminalTitle: '* Codex done', + agentType: 'codex', + agentState: 'done', + agentPrompt: 'Use the late hook status in notification', + agentLastAssistantMessage: 'Late status arrived.' + }) + ) + }) + + it('does not use stale agent status from an earlier turn in task-complete notifications', async () => { + const { connectPanePty } = await import('./pty-connection') + const { useNotificationDispatch } = await vi.importActual< + typeof UseNotificationDispatchModule + >('./use-notification-dispatch') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + const dispatchNotification = useNotificationDispatch('wt-1') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'Previous task that must not leak', + updatedAt: Date.now() - 15_000, + stateStartedAt: Date.now() - 20_000, + agentType: 'codex', + paneKey, + terminalTitle: '* Codex done', + stateHistory: [], + lastAssistantMessage: 'Old response' + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ dispatchNotification }) + + connectPanePty(pane as never, manager as never, deps as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!idleHandler) { + throw new Error('Expected onAgentBecameIdle to be registered') + } + + idleHandler('* Codex done') + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + await flushAsyncTicks() + + const dispatchArgs = (window.api.notifications.dispatch as ReturnType).mock + .calls[0]?.[0] as Record | undefined + if (!dispatchArgs) { + throw new Error('Expected notification dispatch') + } + expect(dispatchArgs).toMatchObject({ + source: 'agent-task-complete', + worktreeId: 'wt-1', + terminalTitle: '* Codex done' + }) + expect('agentPrompt' in dispatchArgs).toBe(false) + expect('agentLastAssistantMessage' in dispatchArgs).toBe(false) + expect('agentType' in dispatchArgs).toBe(false) + }) + + it('does not attach agent fields to terminal-bell notifications', async () => { + const { connectPanePty } = await import('./pty-connection') + const { useNotificationDispatch } = await vi.importActual< + typeof UseNotificationDispatchModule + >('./use-notification-dispatch') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const dispatchNotification = useNotificationDispatch('wt-1') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'Should not leak into BEL', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'codex', + paneKey, + stateHistory: [] + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ dispatchNotification }) + + connectPanePty(pane as never, manager as never, deps as never) + + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + if (!bellHandler) { + throw new Error('Expected onBell to be registered') + } + + vi.useFakeTimers() + bellHandler() + vi.advanceTimersByTime(250) + + const dispatchArgs = (window.api.notifications.dispatch as ReturnType).mock + .calls[0]?.[0] as Record | undefined + if (!dispatchArgs) { + throw new Error('Expected notification dispatch') + } + expect(dispatchArgs.source).toBe('terminal-bell') + expect('agentType' in dispatchArgs).toBe(false) + expect('agentState' in dispatchArgs).toBe(false) + expect('agentPrompt' in dispatchArgs).toBe(false) + expect('agentToolName' in dispatchArgs).toBe(false) + expect('agentToolInput' in dispatchArgs).toBe(false) + expect('agentLastAssistantMessage' in dispatchArgs).toBe(false) + expect('agentTurnOutcome' in dispatchArgs).toBe(false) + }) + + // Why: agent-row removal belongs to process/PTY lifecycle, not title reversion, so interrupts can't disappear the activity row. + it('clears the cache timer without removing agent status when the title tracker sees exit', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Captured transport options expose onAgentExited as an optional zero-argument callback. + const agentExitedHandler = createdTransportOptions[0]?.onAgentExited as + | (() => void) + | undefined + if (!agentExitedHandler) { + throw new Error('Expected onAgentExited to be registered') + } + + agentExitedHandler() + + expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1), null) + expect(deps.onAgentExitedRef.current).toHaveBeenCalledWith(LEAF_1) + expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts deleted file mode 100644 index 95d331e99dd..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts +++ /dev/null @@ -1,729 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './pty-connection-test-constants' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import type * as UseNotificationDispatchModule from './use-notification-dispatch' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { - createInitialStoreState, - buildActiveRuntimeEnvironmentState -} from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. -function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { - mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) -} - -function notifyStoreSubscribers(): void { - for (const listener of storeSubscribers.slice()) { - listener(mockStoreState) - } -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('dispatches agent-task-complete for generic Codex spinner titles after process identity is confirmed', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-codex') - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - const api = ( - globalThis as unknown as { - window: { api: { pty: { getForegroundProcess: ReturnType } } } - } - ).window.api - api.pty.getForegroundProcess.mockResolvedValue('codex') - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - - titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') - titleHandler('experimental-agent-observability', 'experimental-agent-observability') - await flushAsyncTicks() - - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - - expect(deps.dispatchNotification).toHaveBeenCalledWith({ - source: 'agent-task-complete', - terminalTitle: 'experimental-agent-observability', - ptyId: 'tab-pty', - paneKey: makePaneKey('tab-1', LEAF_1) - }) - expect(window.api.pty.inspectProcess).toHaveBeenCalledWith('pty-codex') - }) - - it('does not dispatch generic spinner completions when process inspection finds no agent', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-shell') - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - const api = ( - globalThis as unknown as { - window: { api: { pty: { getForegroundProcess: ReturnType } } } - } - ).window.api - api.pty.getForegroundProcess.mockResolvedValue('zsh') - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - - titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') - titleHandler('experimental-agent-observability', 'experimental-agent-observability') - await flushAsyncTicks() - - vi.advanceTimersByTime(16_000) - - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - }) - - it('dispatches agent-task-complete from recognized hook completion events', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-hook') - transportFactoryQueue.push(transport) - enableActiveRuntimeEnvironment() - mockStoreState.ptyIdsByTabId = { 'tab-1': ['pty-hook'] } - mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { [LEAF_1]: 'pty-hook' } - - vi.useFakeTimers() - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const statusHandler = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { - state: 'done' - prompt: string - agentType: 'codex' - lastAssistantMessage: string - }) => void) - | undefined - if (!statusHandler) { - throw new Error('Expected onAgentStatus to be registered') - } - - statusHandler({ - state: 'done', - prompt: 'finish the implementation', - agentType: 'codex', - lastAssistantMessage: 'Done.' - }) - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - - expect(deps.dispatchNotification).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'agent-task-complete', - terminalTitle: 'codex', - paneKey: makePaneKey('tab-1', LEAF_1) - }) - ) - }) - - it('preserves synthetic Codex permission titles', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-hook') - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { - agentArgs: '', - agentEnv: {} - } - } - - const pane = createPane(1) - const manager = createManager(1) - manager.getActivePane.mockReturnValue({ id: 1 }) - const deps = createDeps({ - startup: { - command: 'codex', - launchConfig: { - agentCommand: 'codex', - agentArgs: '', - agentEnv: {} - }, - launchToken: 'launch-manual', - launchAgent: 'codex' - } - }) - - connectPanePty(pane as never, manager as never, deps as never) - - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - - titleHandler('Codex - action required', 'Codex - action required') - - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex - action required') - expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Codex - action required') - }) - - it('normalizes Pi-compatible remote titles to authoritative OMP launch identity', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-omp') - transportFactoryQueue.push(transport) - enableActiveRuntimeEnvironment() - mockStoreState.tabsByWorktree = { - 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', launchAgent: 'omp' }] - } - mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: '\u280b Pi' } } - - const pane = createPane(1) - const manager = createManager(1) - manager.getActivePane.mockReturnValue({ id: 1 }) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - titleHandler('\u280b Pi', '\u280b Pi') - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, '\u280b OMP') - expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', '\u280b OMP') - titleHandler('π: tmp', 'π: tmp') - expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'OMP: tmp') - expect(deps.updateTabTitle).toHaveBeenLastCalledWith('tab-1', 'OMP: tmp') - - const statusHandler = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'working'; prompt: string; agentType: 'pi' }) => void) - | undefined - if (!statusHandler) { - throw new Error('Expected onAgentStatus to be registered') - } - - statusHandler({ - state: 'working', - prompt: 'fix the remote title', - agentType: 'pi' - }) - - expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( - makePaneKey('tab-1', LEAF_1), - { - state: 'working', - prompt: 'fix the remote title', - agentType: 'omp', - observation: expect.objectContaining({ origin: 'osc', kind: 'snapshot' }) - }, - '\u280b OMP', - undefined, - { connectionId: null } - ) - - mockStoreState.tabsByWorktree = { - 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] - } - statusHandler({ - state: 'working', - prompt: 'keep the remote title', - agentType: 'pi' - }) - - expect(mockStoreState.setAgentStatus).toHaveBeenLastCalledWith( - makePaneKey('tab-1', LEAF_1), - { - state: 'working', - prompt: 'keep the remote title', - agentType: 'omp', - observation: expect.objectContaining({ origin: 'osc', kind: 'snapshot' }) - }, - '\u280b OMP', - undefined, - { connectionId: null } - ) - }) - - it('keeps GPU rendering enabled for OMP titles whose cwd is Gemini', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-omp-gemini-cwd') - transportFactoryQueue.push(transport) - enableActiveRuntimeEnvironment() - mockStoreState.tabsByWorktree = { - 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', launchAgent: 'omp' }] - } - - const pane = createPane(1) - const manager = createManager(1) - manager.getActivePane.mockReturnValue({ id: 1 }) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - - manager.setPaneGpuRendering.mockClear() - - titleHandler('\u280b Pi', '\u280b π: gemini') - - expect(manager.setPaneGpuRendering).toHaveBeenCalledTimes(1) - expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, true) - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, '\u280b OMP') - expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', '\u280b OMP') - }) - - it('leaves local IPC OSC 9999 status ownership in the main runtime', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-local') - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(createdTransportOptions[0]?.onAgentStatus).toBeUndefined() - }) - - it('queues the idle cursor reset behind hidden agent output', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - vi.useFakeTimers() - - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!capturedDataCallback.current || !idleHandler) { - throw new Error('Expected PTY data and idle handlers to be registered') - } - - capturedDataCallback.current('\x1b[6 q') - idleHandler('* Codex done') - - expect(pane.terminal.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(`\x1b[6 q${RESET_TERMINAL_CURSOR_STYLE}`) - }) - - it('waits briefly for delayed agent status before dispatching task-complete', async () => { - const { connectPanePty } = await import('./pty-connection') - const { useNotificationDispatch } = await vi.importActual( - './use-notification-dispatch' - ) - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - const dispatchNotification = useNotificationDispatch('wt-1') - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ dispatchNotification }) - - connectPanePty(pane as never, manager as never, deps as never) - - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!idleHandler) { - throw new Error('Expected onAgentBecameIdle to be registered') - } - - idleHandler('* Codex done') - expect(window.api.notifications.dispatch).not.toHaveBeenCalled() - - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'Use delayed hook status in notification', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'codex', - paneKey, - terminalTitle: '* Codex done', - stateHistory: [], - lastAssistantMessage: 'Delayed status arrived.' - } - - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - await flushAsyncTicks() - - expect(window.api.notifications.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'agent-task-complete', - terminalTitle: '* Codex done', - agentType: 'codex', - agentState: 'done', - agentPrompt: 'Use delayed hook status in notification', - agentLastAssistantMessage: 'Delayed status arrived.' - }) - ) - }) - - it('waits past the grace delay when the assistant message arrives shortly after it', async () => { - const { connectPanePty } = await import('./pty-connection') - const { useNotificationDispatch } = await vi.importActual( - './use-notification-dispatch' - ) - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - const dispatchNotification = useNotificationDispatch('wt-1') - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ dispatchNotification }) - - connectPanePty(pane as never, manager as never, deps as never) - - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!idleHandler) { - throw new Error('Expected onAgentBecameIdle to be registered') - } - - idleHandler('* Codex done') - vi.advanceTimersByTime(250) - await flushAsyncTicks() - expect(window.api.notifications.dispatch).not.toHaveBeenCalled() - - vi.advanceTimersByTime(100) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'Use the late hook status in notification', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'codex', - paneKey, - terminalTitle: '* Codex done', - stateHistory: [], - lastAssistantMessage: 'Late status arrived.' - } - notifyStoreSubscribers() - await flushAsyncTicks() - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS - 350) - await flushAsyncTicks() - - expect(window.api.notifications.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'agent-task-complete', - terminalTitle: '* Codex done', - agentType: 'codex', - agentState: 'done', - agentPrompt: 'Use the late hook status in notification', - agentLastAssistantMessage: 'Late status arrived.' - }) - ) - }) - - it('does not use stale agent status from an earlier turn in task-complete notifications', async () => { - const { connectPanePty } = await import('./pty-connection') - const { useNotificationDispatch } = await vi.importActual( - './use-notification-dispatch' - ) - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - vi.useFakeTimers() - vi.setSystemTime(1_000_000) - const dispatchNotification = useNotificationDispatch('wt-1') - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'Previous task that must not leak', - updatedAt: Date.now() - 15_000, - stateStartedAt: Date.now() - 20_000, - agentType: 'codex', - paneKey, - terminalTitle: '* Codex done', - stateHistory: [], - lastAssistantMessage: 'Old response' - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ dispatchNotification }) - - connectPanePty(pane as never, manager as never, deps as never) - - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!idleHandler) { - throw new Error('Expected onAgentBecameIdle to be registered') - } - - idleHandler('* Codex done') - vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) - await flushAsyncTicks() - - const dispatchArgs = (window.api.notifications.dispatch as ReturnType).mock - .calls[0]?.[0] as Record | undefined - if (!dispatchArgs) { - throw new Error('Expected notification dispatch') - } - expect(dispatchArgs).toMatchObject({ - source: 'agent-task-complete', - worktreeId: 'wt-1', - terminalTitle: '* Codex done' - }) - expect('agentPrompt' in dispatchArgs).toBe(false) - expect('agentLastAssistantMessage' in dispatchArgs).toBe(false) - expect('agentType' in dispatchArgs).toBe(false) - }) - - it('does not attach agent fields to terminal-bell notifications', async () => { - const { connectPanePty } = await import('./pty-connection') - const { useNotificationDispatch } = await vi.importActual( - './use-notification-dispatch' - ) - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - const dispatchNotification = useNotificationDispatch('wt-1') - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'Should not leak into BEL', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'codex', - paneKey, - stateHistory: [] - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ dispatchNotification }) - - connectPanePty(pane as never, manager as never, deps as never) - - const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined - if (!bellHandler) { - throw new Error('Expected onBell to be registered') - } - - vi.useFakeTimers() - bellHandler() - vi.advanceTimersByTime(250) - - const dispatchArgs = (window.api.notifications.dispatch as ReturnType).mock - .calls[0]?.[0] as Record | undefined - if (!dispatchArgs) { - throw new Error('Expected notification dispatch') - } - expect(dispatchArgs.source).toBe('terminal-bell') - expect('agentType' in dispatchArgs).toBe(false) - expect('agentState' in dispatchArgs).toBe(false) - expect('agentPrompt' in dispatchArgs).toBe(false) - expect('agentToolName' in dispatchArgs).toBe(false) - expect('agentToolInput' in dispatchArgs).toBe(false) - expect('agentLastAssistantMessage' in dispatchArgs).toBe(false) - expect('agentTurnOutcome' in dispatchArgs).toBe(false) - }) - - // Why: agent-row removal belongs to process/PTY lifecycle, not title reversion, so interrupts can't disappear the activity row. - it('clears the cache timer without removing agent status when the title tracker sees exit', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const agentExitedHandler = createdTransportOptions[0]?.onAgentExited as (() => void) | undefined - if (!agentExitedHandler) { - throw new Error('Expected onAgentExited to be registered') - } - - agentExitedHandler() - - expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1), null) - expect(deps.onAgentExitedRef.current).toHaveBeenCalledWith(LEAF_1) - expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.spec.ts new file mode 100644 index 00000000000..810d4eda61e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.spec.ts @@ -0,0 +1,859 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createKeyboardEventTarget, + keyEvent, + sendTerminalInputThroughPane +} from './pty-connection-test-dom' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +/** What remountTerminalTabForRecovery answers now that it reports admission. */ +const REMOUNTED = { remounted: true as const, generation: 1 } +const AUTOMATIC_REQUEST = expect.objectContaining({ trigger: 'automatic' }) + +export function registerPtyConnectionTerminalInputGatingTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('does not enumerate every worktree tab for ordinary input without Codex restart notices', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport('pty-live') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: new Proxy( + { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }], + 'wt-2': [{ id: 'tab-2', ptyId: 'pty-other' }] + }, + { + ownKeys() { + throw new Error('tabsByWorktree should not be enumerated') + } + } + ), + codexRestartNoticeByPtyId: {} + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') + }) + + it('uses the current worktree tab for Codex stale fallback without enumerating all worktrees', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport(null) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: new Proxy( + { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }], + 'wt-2': [{ id: 'tab-2', ptyId: 'pty-other' }] + }, + { + ownKeys() { + throw new Error('tabsByWorktree should not be enumerated') + } + } + ), + codexRestartNoticeByPtyId: { + 'pty-other': { previousAccountLabel: 'A', nextAccountLabel: 'B' } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') + }) + + it('blocks stale Codex fallback input from the current worktree tab without enumerating all worktrees', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport(null) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: new Proxy( + { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }], + 'wt-2': [{ id: 'tab-2', ptyId: 'pty-other' }] + }, + { + ownKeys() { + throw new Error('tabsByWorktree should not be enumerated') + } + } + ), + codexRestartNoticeByPtyId: { + 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B' } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + expect(transport.sendInput).not.toHaveBeenCalled() + }) + + it('restores input to a Codex pane whose restart notice the user dismissed', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport('pty-live') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-live'] }, + // Why: the record survives a dismissal as launch-account memory, so the + // input gate has to read the marker rather than the record's existence. + codexRestartNoticeByPtyId: { + 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B', dismissed: true } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') + }) + + it('restores input through the tab fallback when the dismissed pane has no live PTY binding', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport(null) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-live'] }, + codexRestartNoticeByPtyId: { + 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B', dismissed: true } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') + }) + + it('keeps a dismissed split pane typing while a sibling still holds the prompt', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport('pty-dismissed') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + // Why: `tab.ptyId` is whichever pane bound last, so a sibling's unanswered + // notice would otherwise kill this pane's keyboard with no prompt of its own. + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-sibling' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-dismissed', 'pty-sibling'] }, + codexRestartNoticeByPtyId: { + 'pty-dismissed': { previousAccountLabel: 'A', nextAccountLabel: 'B', dismissed: true }, + 'pty-sibling': { previousAccountLabel: 'A', nextAccountLabel: 'C' } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps({ + // Why: the sibling already holds `tab.ptyId`, which is what makes this a + // split rather than this pane adopting the tab's binding. + paneTransportsRef: { current: new Map([[2, createMockTransport('pty-sibling')]]) } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + expect((transport.getPtyId as unknown as () => string | null)()).toBe('pty-dismissed') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') + }) + + it('keeps blocking a pane with its own unanswered notice next to a dismissed sibling', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport('pty-stale') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-sibling' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-stale', 'pty-sibling'] }, + codexRestartNoticeByPtyId: { + 'pty-stale': { previousAccountLabel: 'A', nextAccountLabel: 'B' }, + 'pty-sibling': { previousAccountLabel: 'A', nextAccountLabel: 'B', dismissed: true } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps({ + paneTransportsRef: { current: new Map([[2, createMockTransport('pty-sibling')]]) } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + expect((transport.getPtyId as unknown as () => string | null)()).toBe('pty-stale') + expect(transport.sendInput).not.toHaveBeenCalled() + }) + + it('keeps a bound pane with no notice typing while a sibling holds a queued restart', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport('pty-plain-shell') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + // Why: a requested restart hides the prompt, so blocking this record-less + // pane through `tab.ptyId` would leave a dead keyboard and nothing to answer. + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-sibling' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-plain-shell', 'pty-sibling'] }, + codexRestartNoticeByPtyId: { + 'pty-sibling': { + previousAccountLabel: 'A', + nextAccountLabel: 'B', + restartRequested: true + } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps({ + paneTransportsRef: { current: new Map([[2, createMockTransport('pty-sibling')]]) } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + expect((transport.getPtyId as unknown as () => string | null)()).toBe('pty-plain-shell') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') + }) + + it('keeps blocking input on a pane whose restart is requested but not yet run', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport('pty-live') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-live'] }, + // Why: unlike a dismissal, a queued restart leaves the pane running under + // the old account until it actually relaunches. + codexRestartNoticeByPtyId: { + 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B', restartRequested: true } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('a') + + expect(transport.sendInput).not.toHaveBeenCalled() + }) + + it('blocks input to stale Codex panes until they restart', async () => { + const { connectPanePty } = await import('./pty-connection') + + vi.useFakeTimers() + vi.setSystemTime(1_100) + const transport = createMockTransport('pty-codex-stale') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-codex-stale' }] + }, + ptyIdsByTabId: { + 'tab-1': ['pty-codex-stale'] + }, + codexRestartNoticeByPtyId: { + 'pty-codex-stale': { previousAccountLabel: 'A', nextAccountLabel: 'B' } + }, + agentStatusByPaneKey: { + [makePaneKey('tab-1', LEAF_1)]: { + paneKey: makePaneKey('tab-1', LEAF_1), + state: 'working', + prompt: 'stale input', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + stateHistory: [] + } + } + } + + const pane = createPane(1) + const terminalTarget = createKeyboardEventTarget() + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + const sendTerminalInput = onDataHandler as (data: string) => void + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + sendTerminalInput('\x03') + vi.advanceTimersByTime(500) + + expect(transport.sendInput).not.toHaveBeenCalled() + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + }) + + it('does not infer interrupts when mobile presence lock blocks terminal input', async () => { + const { connectPanePty } = await import('./pty-connection') + const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') + + vi.useFakeTimers() + vi.setSystemTime(1_100) + const ptyId = 'pty-mobile-locked' + setDriverForPty(ptyId, { kind: 'mobile', clientId: 'phone-1' }) + try { + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] }, + ptyIdsByTabId: { 'tab-1': [ptyId] }, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'locked input', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + stateHistory: [] + } + } + } + + const pane = createPane(1) + const terminalTarget = createKeyboardEventTarget() + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + ;(onDataHandler as unknown as (data: string) => void)('x') + vi.advanceTimersByTime(500) + + expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() + expect(transport.sendInput).not.toHaveBeenCalled() + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + } finally { + setDriverForPty(ptyId, { kind: 'idle' }) + } + }) + + it('drops xterm protocol replies from live TUI output while mobile presence lock is active', async () => { + const { connectPanePty } = await import('./pty-connection') + const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') + + const ptyId = 'pty-mobile-tui-query' + setDriverForPty(ptyId, { kind: 'mobile', clientId: 'phone-1' }) + try { + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] }, + ptyIdsByTabId: { 'tab-1': [ptyId] } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + // Simulate xterm answering a TUI's DA1 query while the phone owns the PTY. + ;(onDataHandler as unknown as (data: string) => void)('\x1b[?1;2c') + // Capability handlers are registered outside onData and must honor the same mobile query-authority lock. + const csiCalls = ( + pane.terminal.parser.registerCsiHandler as unknown as { + mock: { calls: [{ final: string }, (params: (number | number[])[]) => boolean][] } + } + ).mock.calls + const da1Handler = csiCalls.find(([id]) => id.final === 'c')?.[1] + expect(da1Handler).toBeTypeOf('function') + da1Handler?.([]) + await flushAsyncTicks() + + expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() + expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.sendInputImmediate).not.toHaveBeenCalled() + } finally { + setDriverForPty(ptyId, { kind: 'idle' }) + } + }) + + it('blocks remote locked terminal input before it reaches the runtime transport', async () => { + const { connectPanePty } = await import('./pty-connection') + const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') + + const ptyId = 'remote:env-1@@terminal-1' + setDriverForPty(ptyId, { kind: 'mobile', clientId: 'phone-1' }) + try { + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] }, + ptyIdsByTabId: { 'tab-1': [ptyId] } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as unknown as (data: string) => void)('x') + await flushAsyncTicks() + + expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() + expect(transport.sendInput).not.toHaveBeenCalled() + } finally { + setDriverForPty(ptyId, { kind: 'idle' }) + } + }) + + it('does not infer interrupts when the transport rejects terminal input', async () => { + const { connectPanePty } = await import('./pty-connection') + + vi.useFakeTimers() + vi.setSystemTime(1_100) + const transport = createMockTransport('pty-disconnected') + transport.sendInput.mockReturnValue(false) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + paneKey, + state: 'working', + prompt: 'disconnected input', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + stateHistory: [] + } + + const pane = createPane(1) + const terminalTarget = createKeyboardEventTarget() + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + vi.advanceTimersByTime(500) + + expect(transport.sendInput).toHaveBeenCalledWith('\x03') + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + }) + + it('does not infer interrupts when the main process rejects acknowledged input', async () => { + const { connectPanePty } = await import('./pty-connection') + + vi.useFakeTimers() + vi.setSystemTime(1_100) + const transport = createMockTransport('pty-mobile-race') + transport.sendInputAccepted = vi.fn().mockResolvedValue(false) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + paneKey, + state: 'working', + prompt: 'mobile race input', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + stateHistory: [] + } + + const pane = createPane(1) + const terminalTarget = createKeyboardEventTarget() + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'query-reply') + expect(transport.sendInput).not.toHaveBeenCalled() + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + }) + + it('remounts a connected pane when main reports its daemon write unavailable', async () => { + const { connectPanePty } = await import('./pty-connection') + const { _resetTerminalPaneRecoveryForTests } = await import('./terminal-pane-recovery') + _resetTerminalPaneRecoveryForTests() + const remountTerminalTabForRecovery = vi.fn(() => REMOUNTED) + mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState + const transport = createMockTransport('daemon-pty') + let writeUnavailable: (() => void) | undefined + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + writeUnavailable = callbacks.onWriteUnavailable + return { id: 'daemon-pty' } + } + ) + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + writeUnavailable?.() + await flushAsyncTicks(6) + + expect(window.api.pty.hasPty).toHaveBeenCalledWith('daemon-pty') + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1', AUTOMATIC_REQUEST) + _resetTerminalPaneRecoveryForTests() + }) + + it('quarantines the interrupted line after a write-unavailable remount, but never device replies', async () => { + const { connectPanePty } = await import('./pty-connection') + const { _resetTerminalPaneRecoveryForTests } = await import('./terminal-pane-recovery') + _resetTerminalPaneRecoveryForTests() + const remountTerminalTabForRecovery = vi.fn(() => REMOUNTED) + mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState + const transport = createMockTransport('daemon-pty') + let writeUnavailable: (() => void) | undefined + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + writeUnavailable = callbacks.onWriteUnavailable + return { id: 'daemon-pty' } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + writeUnavailable?.() + await flushAsyncTicks(6) + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1', AUTOMATIC_REQUEST) + + // The surviving tail of `echo hi; rm -rf x`: reaching the fresh shell would + // let the user's own Enter run `rm -rf x` (#10065 follow-up). + sendTerminalInputThroughPane(pane, 'cho hi; rm -rf x') + expect(transport.sendInput).not.toHaveBeenCalledWith('cho hi; rm -rf x') + // A program that queries during reattach hangs if its reply is dropped. + sendTerminalInputThroughPane(pane, '\x1b[3;1R') + expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[3;1R') + sendTerminalInputThroughPane(pane, '\r') + expect(transport.sendInput).not.toHaveBeenCalledWith('\r') + // The terminator disarmed it, so the next real command reaches the shell. + sendTerminalInputThroughPane(pane, 'ls\r') + expect(transport.sendInput).toHaveBeenCalledWith('ls\r', 'query-reply') + _resetTerminalPaneRecoveryForTests() + }) + + it('recovers a wedged write pipeline after accepted input without renderer output', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const { settleTerminalWriteStallWatch, WRITE_PIPELINE_STALL_CHECK_MS } = + await import('@/lib/pane-manager/terminal-write-pipeline-health') + const remountTerminalTabForRecovery = vi.fn(() => REMOUNTED) + mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState + const transport = createMockTransport('pty-wedged') + transportFactoryQueue.push(transport) + const pane = createPane(1) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks() + pane.terminal.write.mockClear() + pane.terminal.write.mockImplementation(() => {}) + + sendTerminalInputThroughPane(pane, 'x') + settleTerminalWriteStallWatch(pane.terminal) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) + await flushAsyncTicks() + + expect(transport.sendInput).toHaveBeenCalledWith('x', 'query-reply') + expect(pane.terminal.write).toHaveBeenCalledWith('', expect.any(Function)) + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1', AUTOMATIC_REQUEST) + binding.dispose() + }) + + it('does not arm the wedge probe when acknowledged input is rejected', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const { WRITE_PIPELINE_STALL_CHECK_MS } = + await import('@/lib/pane-manager/terminal-write-pipeline-health') + const transport = createMockTransport('pty-rejected') + transport.sendInputAccepted = vi.fn().mockResolvedValue(false) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks() + pane.terminal.write.mockClear() + pane.terminal.write.mockImplementation(() => {}) + + sendTerminalInputThroughPane(pane, '\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) + await flushAsyncTicks() + + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'query-reply') + expect(pane.terminal.write).not.toHaveBeenCalledWith('', expect.any(Function)) + binding.dispose() + }) + + it('blocks input when tab-level ptyId is stale even if panePtyId is null', async () => { + const { connectPanePty } = await import('./pty-connection') + + const transport = createMockTransport(null) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-level-pty' }] + }, + codexRestartNoticeByPtyId: { + 'tab-level-pty': { previousAccountLabel: 'A', nextAccountLabel: 'B' } + } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + expect(onDataHandler).toBeDefined() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as (data: string) => void)('hello') + + expect(transport.sendInput).not.toHaveBeenCalled() + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts deleted file mode 100644 index c26913c75d9..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts +++ /dev/null @@ -1,934 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - createKeyboardEventTarget, - keyEvent, - sendTerminalInputThroughPane -} from './pty-connection-test-dom' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -/** What remountTerminalTabForRecovery answers now that it reports admission. */ -const REMOUNTED = { remounted: true as const, generation: 1 } -const AUTOMATIC_REQUEST = expect.objectContaining({ trigger: 'automatic' }) - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('does not enumerate every worktree tab for ordinary input without Codex restart notices', async () => { - const { connectPanePty } = await import('./pty-connection') - - const transport = createMockTransport('pty-live') - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: new Proxy( - { - 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }], - 'wt-2': [{ id: 'tab-2', ptyId: 'pty-other' }] - }, - { - ownKeys() { - throw new Error('tabsByWorktree should not be enumerated') - } - } - ), - codexRestartNoticeByPtyId: {} - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(onDataHandler).toBeDefined() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as (data: string) => void)('a') - - expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') - }) - - it('uses the current worktree tab for Codex stale fallback without enumerating all worktrees', async () => { - const { connectPanePty } = await import('./pty-connection') - - const transport = createMockTransport(null) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: new Proxy( - { - 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }], - 'wt-2': [{ id: 'tab-2', ptyId: 'pty-other' }] - }, - { - ownKeys() { - throw new Error('tabsByWorktree should not be enumerated') - } - } - ), - codexRestartNoticeByPtyId: { - 'pty-other': { previousAccountLabel: 'A', nextAccountLabel: 'B' } - } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(onDataHandler).toBeDefined() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as (data: string) => void)('a') - - expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') - }) - - it('blocks stale Codex fallback input from the current worktree tab without enumerating all worktrees', async () => { - const { connectPanePty } = await import('./pty-connection') - - const transport = createMockTransport(null) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: new Proxy( - { - 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }], - 'wt-2': [{ id: 'tab-2', ptyId: 'pty-other' }] - }, - { - ownKeys() { - throw new Error('tabsByWorktree should not be enumerated') - } - } - ), - codexRestartNoticeByPtyId: { - 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B' } - } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(onDataHandler).toBeDefined() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as (data: string) => void)('a') - - expect(transport.sendInput).not.toHaveBeenCalled() - }) - - it('restores input to a Codex pane whose restart notice the user dismissed', async () => { - const { connectPanePty } = await import('./pty-connection') - - const transport = createMockTransport('pty-live') - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }] }, - ptyIdsByTabId: { 'tab-1': ['pty-live'] }, - // Why: the record survives a dismissal as launch-account memory, so the - // input gate has to read the marker rather than the record's existence. - codexRestartNoticeByPtyId: { - 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B', dismissed: true } - } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(onDataHandler).toBeDefined() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as (data: string) => void)('a') - - expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') - }) - - it('restores input through the tab fallback when the dismissed pane has no live PTY binding', async () => { - const { connectPanePty } = await import('./pty-connection') - - const transport = createMockTransport(null) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }] }, - ptyIdsByTabId: { 'tab-1': ['pty-live'] }, - codexRestartNoticeByPtyId: { - 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B', dismissed: true } - } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(onDataHandler).toBeDefined() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as (data: string) => void)('a') - - expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') - }) - - it('keeps a dismissed split pane typing while a sibling still holds the prompt', async () => { - const { connectPanePty } = await import('./pty-connection') - - const transport = createMockTransport('pty-dismissed') - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - // Why: `tab.ptyId` is whichever pane bound last, so a sibling's unanswered - // notice would otherwise kill this pane's keyboard with no prompt of its own. - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-sibling' }] }, - ptyIdsByTabId: { 'tab-1': ['pty-dismissed', 'pty-sibling'] }, - codexRestartNoticeByPtyId: { - 'pty-dismissed': { previousAccountLabel: 'A', nextAccountLabel: 'B', dismissed: true }, - 'pty-sibling': { previousAccountLabel: 'A', nextAccountLabel: 'C' } - } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const deps = createDeps({ - // Why: the sibling already holds `tab.ptyId`, which is what makes this a - // split rather than this pane adopting the tab's binding. - paneTransportsRef: { current: new Map([[2, createMockTransport('pty-sibling')]]) } - }) - - connectPanePty(pane as never, manager as never, deps as never) - - expect(onDataHandler).toBeDefined() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as (data: string) => void)('a') - - expect((transport.getPtyId as unknown as () => string | null)()).toBe('pty-dismissed') - expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') - }) - - it('keeps blocking a pane with its own unanswered notice next to a dismissed sibling', async () => { - const { connectPanePty } = await import('./pty-connection') - - const transport = createMockTransport('pty-stale') - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-sibling' }] }, - ptyIdsByTabId: { 'tab-1': ['pty-stale', 'pty-sibling'] }, - codexRestartNoticeByPtyId: { - 'pty-stale': { previousAccountLabel: 'A', nextAccountLabel: 'B' }, - 'pty-sibling': { previousAccountLabel: 'A', nextAccountLabel: 'B', dismissed: true } - } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const deps = createDeps({ - paneTransportsRef: { current: new Map([[2, createMockTransport('pty-sibling')]]) } - }) - - connectPanePty(pane as never, manager as never, deps as never) - - expect(onDataHandler).toBeDefined() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as (data: string) => void)('a') - - expect((transport.getPtyId as unknown as () => string | null)()).toBe('pty-stale') - expect(transport.sendInput).not.toHaveBeenCalled() - }) - - it('keeps a bound pane with no notice typing while a sibling holds a queued restart', async () => { - const { connectPanePty } = await import('./pty-connection') - - const transport = createMockTransport('pty-plain-shell') - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - // Why: a requested restart hides the prompt, so blocking this record-less - // pane through `tab.ptyId` would leave a dead keyboard and nothing to answer. - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-sibling' }] }, - ptyIdsByTabId: { 'tab-1': ['pty-plain-shell', 'pty-sibling'] }, - codexRestartNoticeByPtyId: { - 'pty-sibling': { previousAccountLabel: 'A', nextAccountLabel: 'B', restartRequested: true } - } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const deps = createDeps({ - paneTransportsRef: { current: new Map([[2, createMockTransport('pty-sibling')]]) } - }) - - connectPanePty(pane as never, manager as never, deps as never) - - expect(onDataHandler).toBeDefined() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as (data: string) => void)('a') - - expect((transport.getPtyId as unknown as () => string | null)()).toBe('pty-plain-shell') - expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') - }) - - it('keeps blocking input on a pane whose restart is requested but not yet run', async () => { - const { connectPanePty } = await import('./pty-connection') - - const transport = createMockTransport('pty-live') - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }] }, - ptyIdsByTabId: { 'tab-1': ['pty-live'] }, - // Why: unlike a dismissal, a queued restart leaves the pane running under - // the old account until it actually relaunches. - codexRestartNoticeByPtyId: { - 'pty-live': { previousAccountLabel: 'A', nextAccountLabel: 'B', restartRequested: true } - } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(onDataHandler).toBeDefined() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as (data: string) => void)('a') - - expect(transport.sendInput).not.toHaveBeenCalled() - }) - - it('blocks input to stale Codex panes until they restart', async () => { - const { connectPanePty } = await import('./pty-connection') - - vi.useFakeTimers() - vi.setSystemTime(1_100) - const transport = createMockTransport('pty-codex-stale') - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'pty-codex-stale' }] - }, - ptyIdsByTabId: { - 'tab-1': ['pty-codex-stale'] - }, - codexRestartNoticeByPtyId: { - 'pty-codex-stale': { previousAccountLabel: 'A', nextAccountLabel: 'B' } - }, - agentStatusByPaneKey: { - [makePaneKey('tab-1', LEAF_1)]: { - paneKey: makePaneKey('tab-1', LEAF_1), - state: 'working', - prompt: 'stale input', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'codex', - stateHistory: [] - } - } - } - - const pane = createPane(1) - const terminalTarget = createKeyboardEventTarget() - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(onDataHandler).toBeDefined() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - const sendTerminalInput = onDataHandler as (data: string) => void - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - sendTerminalInput('\x03') - vi.advanceTimersByTime(500) - - expect(transport.sendInput).not.toHaveBeenCalled() - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - }) - - it('does not infer interrupts when mobile presence lock blocks terminal input', async () => { - const { connectPanePty } = await import('./pty-connection') - const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') - - vi.useFakeTimers() - vi.setSystemTime(1_100) - const ptyId = 'pty-mobile-locked' - setDriverForPty(ptyId, { kind: 'mobile', clientId: 'phone-1' }) - try { - const transport = createMockTransport(ptyId) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] }, - ptyIdsByTabId: { 'tab-1': [ptyId] }, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - state: 'working', - prompt: 'locked input', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'codex', - stateHistory: [] - } - } - } - - const pane = createPane(1) - const terminalTarget = createKeyboardEventTarget() - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - ;(onDataHandler as unknown as (data: string) => void)('\x03') - ;(onDataHandler as unknown as (data: string) => void)('x') - vi.advanceTimersByTime(500) - - expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() - expect(transport.sendInput).not.toHaveBeenCalled() - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - } finally { - setDriverForPty(ptyId, { kind: 'idle' }) - } - }) - - it('drops xterm protocol replies from live TUI output while mobile presence lock is active', async () => { - const { connectPanePty } = await import('./pty-connection') - const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') - - const ptyId = 'pty-mobile-tui-query' - setDriverForPty(ptyId, { kind: 'mobile', clientId: 'phone-1' }) - try { - const transport = createMockTransport(ptyId) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] }, - ptyIdsByTabId: { 'tab-1': [ptyId] } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - // Simulate xterm answering a TUI's DA1 query while the phone owns the PTY. - ;(onDataHandler as unknown as (data: string) => void)('\x1b[?1;2c') - // Capability handlers are registered outside onData and must honor the same mobile query-authority lock. - const csiCalls = ( - pane.terminal.parser.registerCsiHandler as unknown as { - mock: { calls: [{ final: string }, (params: (number | number[])[]) => boolean][] } - } - ).mock.calls - const da1Handler = csiCalls.find(([id]) => id.final === 'c')?.[1] - expect(da1Handler).toBeTypeOf('function') - da1Handler?.([]) - await flushAsyncTicks() - - expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() - expect(transport.sendInput).not.toHaveBeenCalled() - expect(transport.sendInputImmediate).not.toHaveBeenCalled() - } finally { - setDriverForPty(ptyId, { kind: 'idle' }) - } - }) - - it('blocks remote locked terminal input before it reaches the runtime transport', async () => { - const { connectPanePty } = await import('./pty-connection') - const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') - - const ptyId = 'remote:env-1@@terminal-1' - setDriverForPty(ptyId, { kind: 'mobile', clientId: 'phone-1' }) - try { - const transport = createMockTransport(ptyId) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] }, - ptyIdsByTabId: { 'tab-1': [ptyId] } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as unknown as (data: string) => void)('x') - await flushAsyncTicks() - - expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() - expect(transport.sendInput).not.toHaveBeenCalled() - } finally { - setDriverForPty(ptyId, { kind: 'idle' }) - } - }) - - it('does not infer interrupts when the transport rejects terminal input', async () => { - const { connectPanePty } = await import('./pty-connection') - - vi.useFakeTimers() - vi.setSystemTime(1_100) - const transport = createMockTransport('pty-disconnected') - transport.sendInput.mockReturnValue(false) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - paneKey, - state: 'working', - prompt: 'disconnected input', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'codex', - stateHistory: [] - } - - const pane = createPane(1) - const terminalTarget = createKeyboardEventTarget() - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - ;(onDataHandler as unknown as (data: string) => void)('\x03') - vi.advanceTimersByTime(500) - - expect(transport.sendInput).toHaveBeenCalledWith('\x03') - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - }) - - it('does not infer interrupts when the main process rejects acknowledged input', async () => { - const { connectPanePty } = await import('./pty-connection') - - vi.useFakeTimers() - vi.setSystemTime(1_100) - const transport = createMockTransport('pty-mobile-race') - transport.sendInputAccepted = vi.fn().mockResolvedValue(false) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - paneKey, - state: 'working', - prompt: 'mobile race input', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'codex', - stateHistory: [] - } - - const pane = createPane(1) - const terminalTarget = createKeyboardEventTarget() - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - ;(onDataHandler as unknown as (data: string) => void)('\x03') - await flushAsyncTicks() - vi.advanceTimersByTime(500) - - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'query-reply') - expect(transport.sendInput).not.toHaveBeenCalled() - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - }) - - it('remounts a connected pane when main reports its daemon write unavailable', async () => { - const { connectPanePty } = await import('./pty-connection') - const { _resetTerminalPaneRecoveryForTests } = await import('./terminal-pane-recovery') - _resetTerminalPaneRecoveryForTests() - const remountTerminalTabForRecovery = vi.fn(() => REMOUNTED) - mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState - const transport = createMockTransport('daemon-pty') - let writeUnavailable: (() => void) | undefined - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - writeUnavailable = callbacks.onWriteUnavailable - return { id: 'daemon-pty' } - }) - transportFactoryQueue.push(transport) - - connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - writeUnavailable?.() - await flushAsyncTicks(6) - - expect(window.api.pty.hasPty).toHaveBeenCalledWith('daemon-pty') - expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1', AUTOMATIC_REQUEST) - _resetTerminalPaneRecoveryForTests() - }) - - it('quarantines the interrupted line after a write-unavailable remount, but never device replies', async () => { - const { connectPanePty } = await import('./pty-connection') - const { _resetTerminalPaneRecoveryForTests } = await import('./terminal-pane-recovery') - _resetTerminalPaneRecoveryForTests() - const remountTerminalTabForRecovery = vi.fn(() => REMOUNTED) - mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState - const transport = createMockTransport('daemon-pty') - let writeUnavailable: (() => void) | undefined - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - writeUnavailable = callbacks.onWriteUnavailable - return { id: 'daemon-pty' } - }) - transportFactoryQueue.push(transport) - const pane = createPane(1) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - writeUnavailable?.() - await flushAsyncTicks(6) - expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1', AUTOMATIC_REQUEST) - - // The surviving tail of `echo hi; rm -rf x`: reaching the fresh shell would - // let the user's own Enter run `rm -rf x` (#10065 follow-up). - sendTerminalInputThroughPane(pane, 'cho hi; rm -rf x') - expect(transport.sendInput).not.toHaveBeenCalledWith('cho hi; rm -rf x') - // A program that queries during reattach hangs if its reply is dropped. - sendTerminalInputThroughPane(pane, '\x1b[3;1R') - expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[3;1R') - sendTerminalInputThroughPane(pane, '\r') - expect(transport.sendInput).not.toHaveBeenCalledWith('\r') - // The terminator disarmed it, so the next real command reaches the shell. - sendTerminalInputThroughPane(pane, 'ls\r') - expect(transport.sendInput).toHaveBeenCalledWith('ls\r', 'query-reply') - _resetTerminalPaneRecoveryForTests() - }) - - it('recovers a wedged write pipeline after accepted input without renderer output', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const { settleTerminalWriteStallWatch, WRITE_PIPELINE_STALL_CHECK_MS } = - await import('@/lib/pane-manager/terminal-write-pipeline-health') - const remountTerminalTabForRecovery = vi.fn(() => REMOUNTED) - mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState - const transport = createMockTransport('pty-wedged') - transportFactoryQueue.push(transport) - const pane = createPane(1) - const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - pane.terminal.write.mockClear() - pane.terminal.write.mockImplementation(() => {}) - - sendTerminalInputThroughPane(pane, 'x') - settleTerminalWriteStallWatch(pane.terminal) - vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) - await flushAsyncTicks() - - expect(transport.sendInput).toHaveBeenCalledWith('x', 'query-reply') - expect(pane.terminal.write).toHaveBeenCalledWith('', expect.any(Function)) - expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1', AUTOMATIC_REQUEST) - binding.dispose() - }) - - it('does not arm the wedge probe when acknowledged input is rejected', async () => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - const { WRITE_PIPELINE_STALL_CHECK_MS } = - await import('@/lib/pane-manager/terminal-write-pipeline-health') - const transport = createMockTransport('pty-rejected') - transport.sendInputAccepted = vi.fn().mockResolvedValue(false) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks() - pane.terminal.write.mockClear() - pane.terminal.write.mockImplementation(() => {}) - - sendTerminalInputThroughPane(pane, '\x03') - await flushAsyncTicks() - vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) - await flushAsyncTicks() - - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'query-reply') - expect(pane.terminal.write).not.toHaveBeenCalledWith('', expect.any(Function)) - binding.dispose() - }) - - it('blocks input when tab-level ptyId is stale even if panePtyId is null', async () => { - const { connectPanePty } = await import('./pty-connection') - - const transport = createMockTransport(null) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'tab-level-pty' }] - }, - codexRestartNoticeByPtyId: { - 'tab-level-pty': { previousAccountLabel: 'A', nextAccountLabel: 'B' } - } - } - - const pane = createPane(1) - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - expect(onDataHandler).toBeDefined() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') - } - ;(onDataHandler as (data: string) => void)('hello') - - expect(transport.sendInput).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.spec.ts new file mode 100644 index 00000000000..7470fd69b33 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.spec.ts @@ -0,0 +1,724 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { sendTerminalInputThroughPane } from './pty-connection-test-dom' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { + createInitialStoreState, + buildActiveRuntimeEnvironmentState +} from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionTypedAgentIdentityTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + // Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. + function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { + mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('normalizes Pi-compatible remote runtime status to OMP after typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-omp') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('drives runtime title, tab title, and renderer policy from one title decision', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-gemini') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange) { + throw new Error('missing title callback') + } + onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') + + // Display/runtime/tab title and the GPU gate all come from the same decision. + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, '✦ Gemini CLI') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', '✦ Gemini CLI') + // Genuine Gemini under the default `auto` setting takes the DOM fallback. + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, false) + }) + + it('keeps GPU enabled when a pane-scoped OMP owner emits a Gemini-looking title', async () => { + const { connectPanePty } = await import('./pty-connection') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + agentType: 'omp', + state: 'working', + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + } + } as StoreState + const pane = createPane(1) + const transport = createMockTransport('pty-omp-gemini-cwd') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange) { + throw new Error('missing title callback') + } + onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') + + // Pane-scoped owner evidence outranks the raw title, so the fallback cannot fire. + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, true) + expect(manager.setPaneGpuRendering).not.toHaveBeenCalledWith(1, false) + }) + + it('does not let one split pane title change another pane GPU state', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane1 = createPane(1) + const pane2 = createPane(2) + const transport1 = createMockTransport('pty-split-1') + const transport2 = createMockTransport('pty-split-2') + transportFactoryQueue.push(transport1, transport2) + const manager = createManager(2, 1) + + connectPanePty(pane1 as never, manager as never, createDeps() as never) + connectPanePty(pane2 as never, manager as never, createDeps() as never) + await flushAsyncTicks() + const onTitleChange1 = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange1) { + throw new Error('missing title callback for split pane 1') + } + onTitleChange1('✦ Gemini CLI', '✦ Gemini CLI') + + const gpuCalls = manager.setPaneGpuRendering.mock.calls as [number, boolean][] + expect(gpuCalls.some(([paneId]) => paneId === 1)).toBe(true) + expect(gpuCalls.every(([paneId]) => paneId !== 2)).toBe(true) + }) + + it('DOM-gates a genuine Gemini split pane even when the tab launched a non-Gemini agent', async () => { + const { connectPanePty } = await import('./pty-connection') + // The shared tab.launchAgent must not veto the renderer for a sibling pane. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', launchAgent: 'omp' }] + } + } as StoreState + const pane1 = createPane(1) + const pane2 = createPane(2) + const transport1 = createMockTransport('pty-split-owner-1') + const transport2 = createMockTransport('pty-split-owner-2') + transportFactoryQueue.push(transport1, transport2) + const manager = createManager(2, 2) + + connectPanePty(pane1 as never, manager as never, createDeps() as never) + connectPanePty(pane2 as never, manager as never, createDeps() as never) + await flushAsyncTicks() + const onTitleChange2 = createdTransportOptions[1]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange2) { + throw new Error('missing title callback for split pane 2') + } + onTitleChange2('✦ Gemini CLI', '✦ Gemini CLI') + + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(2, false) + }) + + it('DOM-gates a genuine Gemini title in a pane whose launch agent was non-Gemini', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-reused-gemini') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + // Stale, never-cleared launch identity must not veto the renderer. + const deps = createDeps({ startup: { command: 'claude', launchAgent: 'claude' } }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange) { + throw new Error('missing title callback') + } + onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') + + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, false) + }) + + it('DOM-gates a genuine Gemini title when the only pane row is a done non-Gemini agent', async () => { + const { connectPanePty } = await import('./pty-connection') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + agentType: 'claude', + state: 'done', + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + } + } as StoreState + const pane = createPane(1) + const transport = createMockTransport('pty-reused-done-row') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange) { + throw new Error('missing title callback') + } + onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') + + // A `done` row is a leftover from a prior agent, so it must not veto. + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, false) + }) + + it('DOM-gates a genuine Gemini title when the only pane row is a stale non-Gemini agent', async () => { + const { connectPanePty } = await import('./pty-connection') + const paneKey = makePaneKey('tab-1', LEAF_1) + const staleAt = Date.now() - 60 * 60 * 1000 + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + agentType: 'claude', + state: 'working', + prompt: '', + updatedAt: staleAt, + stateStartedAt: staleAt, + stateHistory: [] + } + } + } as StoreState + const pane = createPane(1) + const transport = createMockTransport('pty-reused-stale-row') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange) { + throw new Error('missing title callback') + } + onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') + + // A stale working row (older than AGENT_STATUS_STALE_AFTER_MS) must not veto. + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, false) + }) + + it('normalizes after shell word deletion edits a typed command to omp', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-omp-edited') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'pi \x17omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r', 'query-reply') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('keeps Pi-compatible remote runtime status as Pi after typed pi command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'pi\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('pi\r', 'query-reply') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('does not infer shell ownership from prompts typed inside an existing Pi session', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: '', + agentType: 'pi', + paneKey, + terminalTitle: 'Pi ready', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi-prompt') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('does not infer shell ownership from prompts typed in a title-only Pi session', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi-title-only') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('lets a new typed omp command override a stale retained done status', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: '', + agentType: 'pi', + paneKey, + terminalTitle: 'Pi ready', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-stale-done') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('tracks cursor edits when inferring a typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-cursor-edit') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'op\x1b[Dm\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r', 'query-reply') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('tracks delete-key cursor edits when inferring a typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-delete-edit') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'ommp\x1b[D\x1b[D\x1b[3~\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('skips manual agent inference for large paste chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-large-paste') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, `${'x'.repeat(4097)}omp\r`) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('resumes manual agent inference when large paste input is cancelled', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-cancelled-large-paste') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'x'.repeat(4097)) + sendTerminalInputThroughPane(pane, '\x03') + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('preserves typed shell ownership through same-chunk command-finished side effects', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const transport = createMockTransport('remote:env-1@@pty-command-finished') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks?.onData ?? null + return 'remote:env-1@@pty-command-finished' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + const dataCallback = dataCallbackRef.current + if (!dataCallback || !onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + dataCallback('\x1b]133;D;0\x07') + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts deleted file mode 100644 index 05056d3c73b..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts +++ /dev/null @@ -1,815 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { sendTerminalInputThroughPane } from './pty-connection-test-dom' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { - createInitialStoreState, - buildActiveRuntimeEnvironmentState -} from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status. -function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { - mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('normalizes Pi-compatible remote runtime status to OMP after typed omp command', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const transport = createMockTransport('remote:web-env-1@@pty-omp') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - sendTerminalInputThroughPane(pane, 'omp\r') - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) - | undefined - if (!onTitleChange || !onAgentStatus) { - throw new Error('missing remote PTY callbacks') - } - onTitleChange('Pi ready', 'Pi ready') - onAgentStatus({ - state: 'done', - prompt: '', - agentType: 'pi' - }) - - expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') - expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'OMP ready') - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - agentType: 'omp', - terminalTitle: 'OMP ready' - }) - }) - - it('drives runtime title, tab title, and renderer policy from one title decision', async () => { - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const transport = createMockTransport('pty-gemini') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!onTitleChange) { - throw new Error('missing title callback') - } - onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') - - // Display/runtime/tab title and the GPU gate all come from the same decision. - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, '✦ Gemini CLI') - expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', '✦ Gemini CLI') - // Genuine Gemini under the default `auto` setting takes the DOM fallback. - expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, false) - }) - - it('keeps GPU enabled when a pane-scoped OMP owner emits a Gemini-looking title', async () => { - const { connectPanePty } = await import('./pty-connection') - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - agentType: 'omp', - state: 'working', - prompt: '', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - stateHistory: [] - } - } - } as StoreState - const pane = createPane(1) - const transport = createMockTransport('pty-omp-gemini-cwd') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!onTitleChange) { - throw new Error('missing title callback') - } - onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') - - // Pane-scoped owner evidence outranks the raw title, so the fallback cannot fire. - expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, true) - expect(manager.setPaneGpuRendering).not.toHaveBeenCalledWith(1, false) - }) - - it('does not let one split pane title change another pane GPU state', async () => { - const { connectPanePty } = await import('./pty-connection') - const pane1 = createPane(1) - const pane2 = createPane(2) - const transport1 = createMockTransport('pty-split-1') - const transport2 = createMockTransport('pty-split-2') - transportFactoryQueue.push(transport1, transport2) - const manager = createManager(2, 1) - - connectPanePty(pane1 as never, manager as never, createDeps() as never) - connectPanePty(pane2 as never, manager as never, createDeps() as never) - await flushAsyncTicks() - const onTitleChange1 = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!onTitleChange1) { - throw new Error('missing title callback for split pane 1') - } - onTitleChange1('✦ Gemini CLI', '✦ Gemini CLI') - - const gpuCalls = manager.setPaneGpuRendering.mock.calls as [number, boolean][] - expect(gpuCalls.some(([paneId]) => paneId === 1)).toBe(true) - expect(gpuCalls.every(([paneId]) => paneId !== 2)).toBe(true) - }) - - it('DOM-gates a genuine Gemini split pane even when the tab launched a non-Gemini agent', async () => { - const { connectPanePty } = await import('./pty-connection') - // The shared tab.launchAgent must not veto the renderer for a sibling pane. - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', launchAgent: 'omp' }] - } - } as StoreState - const pane1 = createPane(1) - const pane2 = createPane(2) - const transport1 = createMockTransport('pty-split-owner-1') - const transport2 = createMockTransport('pty-split-owner-2') - transportFactoryQueue.push(transport1, transport2) - const manager = createManager(2, 2) - - connectPanePty(pane1 as never, manager as never, createDeps() as never) - connectPanePty(pane2 as never, manager as never, createDeps() as never) - await flushAsyncTicks() - const onTitleChange2 = createdTransportOptions[1]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!onTitleChange2) { - throw new Error('missing title callback for split pane 2') - } - onTitleChange2('✦ Gemini CLI', '✦ Gemini CLI') - - expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(2, false) - }) - - it('DOM-gates a genuine Gemini title in a pane whose launch agent was non-Gemini', async () => { - const { connectPanePty } = await import('./pty-connection') - const pane = createPane(1) - const transport = createMockTransport('pty-reused-gemini') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - // Stale, never-cleared launch identity must not veto the renderer. - const deps = createDeps({ startup: { command: 'claude', launchAgent: 'claude' } }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!onTitleChange) { - throw new Error('missing title callback') - } - onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') - - expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, false) - }) - - it('DOM-gates a genuine Gemini title when the only pane row is a done non-Gemini agent', async () => { - const { connectPanePty } = await import('./pty-connection') - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - agentType: 'claude', - state: 'done', - prompt: '', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - stateHistory: [] - } - } - } as StoreState - const pane = createPane(1) - const transport = createMockTransport('pty-reused-done-row') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!onTitleChange) { - throw new Error('missing title callback') - } - onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') - - // A `done` row is a leftover from a prior agent, so it must not veto. - expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, false) - }) - - it('DOM-gates a genuine Gemini title when the only pane row is a stale non-Gemini agent', async () => { - const { connectPanePty } = await import('./pty-connection') - const paneKey = makePaneKey('tab-1', LEAF_1) - const staleAt = Date.now() - 60 * 60 * 1000 - mockStoreState = { - ...mockStoreState, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - agentType: 'claude', - state: 'working', - prompt: '', - updatedAt: staleAt, - stateStartedAt: staleAt, - stateHistory: [] - } - } - } as StoreState - const pane = createPane(1) - const transport = createMockTransport('pty-reused-stale-row') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!onTitleChange) { - throw new Error('missing title callback') - } - onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') - - // A stale working row (older than AGENT_STATUS_STALE_AFTER_MS) must not veto. - expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, false) - }) - - it('normalizes after shell word deletion edits a typed command to omp', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const transport = createMockTransport('remote:web-env-1@@pty-omp-edited') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - sendTerminalInputThroughPane(pane, 'pi \x17omp\r') - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) - | undefined - if (!onTitleChange || !onAgentStatus) { - throw new Error('missing remote PTY callbacks') - } - onTitleChange('Pi ready', 'Pi ready') - onAgentStatus({ - state: 'done', - prompt: '', - agentType: 'pi' - }) - - expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r', 'query-reply') - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - agentType: 'omp', - terminalTitle: 'OMP ready' - }) - }) - - it('keeps Pi-compatible remote runtime status as Pi after typed pi command', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const transport = createMockTransport('remote:web-env-1@@pty-pi') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - sendTerminalInputThroughPane(pane, 'pi\r') - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) - | undefined - if (!onTitleChange || !onAgentStatus) { - throw new Error('missing remote PTY callbacks') - } - onTitleChange('Pi ready', 'Pi ready') - onAgentStatus({ - state: 'done', - prompt: '', - agentType: 'pi' - }) - - expect(transport.sendInput).toHaveBeenCalledWith('pi\r', 'query-reply') - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') - expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Pi ready') - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - agentType: 'pi', - terminalTitle: 'Pi ready' - }) - }) - - it('does not infer shell ownership from prompts typed inside an existing Pi session', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - const now = Date.now() - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: '', - agentType: 'pi', - paneKey, - terminalTitle: 'Pi ready', - updatedAt: now, - stateStartedAt: now, - stateHistory: [] - } - mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } - const pane = createPane(1) - const transport = createMockTransport('remote:web-env-1@@pty-pi-prompt') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - sendTerminalInputThroughPane(pane, 'omp\r') - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) - | undefined - if (!onTitleChange || !onAgentStatus) { - throw new Error('missing remote PTY callbacks') - } - onTitleChange('Pi ready', 'Pi ready') - onAgentStatus({ - state: 'done', - prompt: '', - agentType: 'pi' - }) - - expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - agentType: 'pi', - terminalTitle: 'Pi ready' - }) - }) - - it('does not infer shell ownership from prompts typed in a title-only Pi session', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } - const pane = createPane(1) - const transport = createMockTransport('remote:web-env-1@@pty-pi-title-only') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - sendTerminalInputThroughPane(pane, 'omp\r') - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) - | undefined - if (!onTitleChange || !onAgentStatus) { - throw new Error('missing remote PTY callbacks') - } - onTitleChange('Pi ready', 'Pi ready') - onAgentStatus({ - state: 'done', - prompt: '', - agentType: 'pi' - }) - - expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - agentType: 'pi', - terminalTitle: 'Pi ready' - }) - }) - - it('lets a new typed omp command override a stale retained done status', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - const now = Date.now() - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: '', - agentType: 'pi', - paneKey, - terminalTitle: 'Pi ready', - updatedAt: now, - stateStartedAt: now, - stateHistory: [] - } - const pane = createPane(1) - const transport = createMockTransport('remote:web-env-1@@pty-stale-done') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - sendTerminalInputThroughPane(pane, 'omp\r') - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) - | undefined - if (!onTitleChange || !onAgentStatus) { - throw new Error('missing remote PTY callbacks') - } - onTitleChange('Pi ready', 'Pi ready') - onAgentStatus({ - state: 'done', - prompt: '', - agentType: 'pi' - }) - - expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - agentType: 'omp', - terminalTitle: 'OMP ready' - }) - }) - - it('tracks cursor edits when inferring a typed omp command', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const transport = createMockTransport('remote:web-env-1@@pty-cursor-edit') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - sendTerminalInputThroughPane(pane, 'op\x1b[Dm\r') - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) - | undefined - if (!onTitleChange || !onAgentStatus) { - throw new Error('missing remote PTY callbacks') - } - onTitleChange('Pi ready', 'Pi ready') - onAgentStatus({ - state: 'done', - prompt: '', - agentType: 'pi' - }) - - expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r', 'query-reply') - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - agentType: 'omp', - terminalTitle: 'OMP ready' - }) - }) - - it('tracks delete-key cursor edits when inferring a typed omp command', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const transport = createMockTransport('remote:web-env-1@@pty-delete-edit') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - sendTerminalInputThroughPane(pane, 'ommp\x1b[D\x1b[D\x1b[3~\r') - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) - | undefined - if (!onTitleChange || !onAgentStatus) { - throw new Error('missing remote PTY callbacks') - } - onTitleChange('Pi ready', 'Pi ready') - onAgentStatus({ - state: 'done', - prompt: '', - agentType: 'pi' - }) - - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - agentType: 'omp', - terminalTitle: 'OMP ready' - }) - }) - - it('skips manual agent inference for large paste chunks', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const transport = createMockTransport('remote:web-env-1@@pty-large-paste') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - sendTerminalInputThroughPane(pane, `${'x'.repeat(4097)}omp\r`) - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) - | undefined - if (!onTitleChange || !onAgentStatus) { - throw new Error('missing remote PTY callbacks') - } - onTitleChange('Pi ready', 'Pi ready') - onAgentStatus({ - state: 'done', - prompt: '', - agentType: 'pi' - }) - - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - agentType: 'pi', - terminalTitle: 'Pi ready' - }) - }) - - it('resumes manual agent inference when large paste input is cancelled', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const transport = createMockTransport('remote:web-env-1@@pty-cancelled-large-paste') - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - sendTerminalInputThroughPane(pane, 'x'.repeat(4097)) - sendTerminalInputThroughPane(pane, '\x03') - sendTerminalInputThroughPane(pane, 'omp\r') - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) - | undefined - if (!onTitleChange || !onAgentStatus) { - throw new Error('missing remote PTY callbacks') - } - onTitleChange('Pi ready', 'Pi ready') - onAgentStatus({ - state: 'done', - prompt: '', - agentType: 'pi' - }) - - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - agentType: 'omp', - terminalTitle: 'OMP ready' - }) - }) - - it('preserves typed shell ownership through same-chunk command-finished side effects', async () => { - const { connectPanePty } = await import('./pty-connection') - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const pane = createPane(1) - const transport = createMockTransport('remote:env-1@@pty-command-finished') - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks?.onData ?? null - return 'remote:env-1@@pty-command-finished' - } - ) - transportFactoryQueue.push(transport) - const manager = createManager(1, 1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - sendTerminalInputThroughPane(pane, 'omp\r') - await flushAsyncTicks() - const onTitleChange = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) - | undefined - const dataCallback = dataCallbackRef.current - if (!dataCallback || !onTitleChange || !onAgentStatus) { - throw new Error('missing remote PTY callbacks') - } - dataCallback('\x1b]133;D;0\x07') - onTitleChange('Pi ready', 'Pi ready') - onAgentStatus({ - state: 'done', - prompt: '', - agentType: 'pi' - }) - - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ - state: 'done', - agentType: 'omp', - terminalTitle: 'OMP ready' - }) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.spec.ts new file mode 100644 index 00000000000..084a95fb9ee --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.spec.ts @@ -0,0 +1,119 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionUnansweredCommandFinishTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('command-finished cleanup when the shell check cannot answer', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it.each([ + ['clears', 'a row without a process identity', false], + ['keeps', 'a row whose agent process the host can check', true] + ])( + '%s %s when an SSH pane finishes and the shell check cannot answer', + async (_verb, _label, verifiable) => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) + const hasVerifiableAgentProcess = vi.fn(async () => verifiable) + Object.assign(window.api.agentStatus, { hasVerifiableAgentProcess }) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'ssh:conn@@pty-codex-exit' + const tabId = 'tab-ssh-codex-exit' + const paneKey = makePaneKey(tabId, LEAF_1) + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'remote task', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + terminalTitle: 'Codex', + stateHistory: [] + } + } + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'codex' } + } + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(hasVerifiableAgentProcess).toHaveBeenCalledWith(paneKey) + if (verifiable) { + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + } else { + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + } + } + ) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts deleted file mode 100644 index 15ca1f989fb..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - LEAF_1, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('command-finished cleanup when the shell check cannot answer', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it.each([ - ['clears', 'a row without a process identity', false], - ['keeps', 'a row whose agent process the host can check', true] - ])( - '%s %s when an SSH pane finishes and the shell check cannot answer', - async (_verb, _label, verifiable) => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) - const hasVerifiableAgentProcess = vi.fn(async () => verifiable) - Object.assign(window.api.agentStatus, { hasVerifiableAgentProcess }) - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'ssh:conn@@pty-codex-exit' - const tabId = 'tab-ssh-codex-exit' - const paneKey = makePaneKey(tabId, LEAF_1) - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - } - ) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - state: 'done', - prompt: 'remote task', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'codex', - terminalTitle: 'Codex', - stateHistory: [] - } - } - } - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ tabId, isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'codex' } - } - - dataCallbackRef.current?.('\x1b]133;D;0\x07') - await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - await flushAsyncTicks() - - expect(hasVerifiableAgentProcess).toHaveBeenCalledWith(paneKey) - if (verifiable) { - expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - } else { - expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) - } - } - ) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts new file mode 100644 index 00000000000..adb2e1e0171 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts @@ -0,0 +1,17 @@ +import { describe } from 'vitest' +import { registerPtyConnectionVisibilityResumeSizeTests } from './pty-connection-visibility-resume-size.spec' +import { registerPtyConnectionHiddenCodexQueriesTests } from './pty-connection-hidden-codex-queries.spec' +import { registerPtyConnectionHiddenDeliveryGateTests } from './pty-connection-hidden-delivery-gate.spec' +import { registerPtyConnectionParkRevealModelSnapshotTests } from './pty-connection-park-reveal-model-snapshot.spec' +import { registerPtyConnectionReplayPayloadHandlingTests } from './pty-connection-replay-payload-handling.spec' +import { registerPtyConnectionPtyExitTeardownTests } from './pty-connection-pty-exit-teardown.spec' + +describe('pty-connection-visibility-resume-size', registerPtyConnectionVisibilityResumeSizeTests) +describe('pty-connection-hidden-codex-queries', registerPtyConnectionHiddenCodexQueriesTests) +describe('pty-connection-hidden-delivery-gate', registerPtyConnectionHiddenDeliveryGateTests) +describe( + 'pty-connection-park-reveal-model-snapshot', + registerPtyConnectionParkRevealModelSnapshotTests +) +describe('pty-connection-replay-payload-handling', registerPtyConnectionReplayPayloadHandlingTests) +describe('pty-connection-pty-exit-teardown', registerPtyConnectionPtyExitTeardownTests) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.spec.ts new file mode 100644 index 00000000000..92625635bdb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.spec.ts @@ -0,0 +1,708 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_2, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionVisibilityResumeSizeTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + describe('PTY size re-assert on visibility resume', () => { + // Why: a resize dropped while hidden leaves xterm and the PTY diverged, and dedupe hides it; resume re-asserts on real drift. + async function connectResumablePane(depsOverrides: Record = {}): Promise<{ + binding: { noteVisibilityResume: () => void } + transport: MockTransport + deps: ReturnType + pane: ReturnType + }> { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }, + ...depsOverrides + }) + const pane = createPane(2) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The real PanePtyBinding exposes noteVisibilityResume; this fixture narrows it to that method. + const binding = connectPanePty( + pane as never, + manager as never, + deps as never + ) as unknown as { + noteVisibilityResume: () => void + } + return { binding, transport, deps, pane } + } + + function installObservedPane(pane: ReturnType): { + trigger: () => void + restore: () => void + } { + const originalResizeObserver = globalThis.ResizeObserver + const originalElement = globalThis.Element + const hadResizeObserver = 'ResizeObserver' in globalThis + const hadElement = 'Element' in globalThis + type ResizeObserverCallbackLike = ConstructorParameters[0] + class MockElement extends EventTarget { + dataset: Record = {} + classList = { contains: (className: string) => className === 'pane' } + + querySelectorAll(): MockElement[] { + return [] + } + } + class MockResizeObserver { + static instances: MockResizeObserver[] = [] + observe = vi.fn() + disconnect = vi.fn() + + constructor(private readonly callback: ResizeObserverCallbackLike) { + MockResizeObserver.instances.push(this) + } + + trigger(): void { + this.callback([], this as never) + } + } + + globalThis.Element = MockElement as never + globalThis.ResizeObserver = MockResizeObserver as never + pane.container = new MockElement() as unknown as HTMLElement + + return { + trigger: () => MockResizeObserver.instances[0]?.trigger(), + restore: () => { + if (hadResizeObserver) { + globalThis.ResizeObserver = originalResizeObserver + } else { + Reflect.deleteProperty(globalThis, 'ResizeObserver') + } + if (hadElement) { + globalThis.Element = originalElement + } else { + Reflect.deleteProperty(globalThis, 'Element') + } + } + } + } + + it('re-asserts the current size when the PTY drifted from xterm', async () => { + vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 }) + const { binding, transport } = await connectResumablePane() + transport.resize.mockClear() + + binding.noteVisibilityResume() + await flushAsyncTicks() + + // xterm is 120x40 (createPane default), PTY reports 80x24 → re-assert. + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) + }) + + it('does not fit during visibility-resume reassertion', async () => { + vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 }) + const { binding, transport, pane } = await connectResumablePane() + const fit = vi.fn(() => { + pane.terminal.cols = 132 + pane.terminal.rows = 40 + }) + pane.fitAddon = { + ...pane.fitAddon, + fit, + proposeDimensions: vi.fn(() => ({ cols: 132, rows: 40 })) + } as never + Object.defineProperty(pane.container, 'getBoundingClientRect', { + configurable: true, + value: () => ({ width: 1130, height: 688 }) + }) + transport.resize.mockClear() + + binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(fit).not.toHaveBeenCalled() + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) + }) + + it('re-asserts after observed pane geometry changes while visible', async () => { + const pane = createPane(2) + const observer = installObservedPane(pane) + try { + vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 200, rows: 40 }) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + pane.terminal.cols = 82 + pane.terminal.rows = 40 + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + transport.resize.mockClear() + vi.mocked(window.api.pty.getSize).mockClear() + + observer.trigger() + await flushAsyncTicks() + + expect(window.api.pty.getSize).toHaveBeenCalledWith('pty-pane-2') + expect(transport.resize).toHaveBeenCalledWith(82, 40, { claim: true }) + } finally { + observer.restore() + } + }) + + it('repairs stale xterm grid drift on foreground output even without a pane resize', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-pane-2' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const pane = createPane(2) + let proposedGrid = { cols: 62, rows: 63 } + pane.terminal.cols = 62 + pane.terminal.rows = 63 + pane.fitAddon = { + ...pane.fitAddon, + fit: vi.fn(() => { + pane.terminal.cols = proposedGrid.cols + pane.terminal.rows = proposedGrid.rows + }), + proposeDimensions: vi.fn(() => proposedGrid) + } as never + vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 62, rows: 63 }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + proposedGrid = { cols: 65, rows: 63 } + vi.mocked(pane.fitAddon.fit).mockClear() + transport.resize.mockClear() + vi.mocked(window.api.pty.getSize).mockClear() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b[?2026hcodex redraw frame') + await flushAsyncTicks() + + expect(pane.fitAddon.fit).toHaveBeenCalled() + expect(window.api.pty.getSize).toHaveBeenCalledWith('pty-pane-2') + expect(transport.resize).toHaveBeenCalledWith(65, 63, { claim: true }) + }) + + it('skips foreground grid drift repair while mobile owns the PTY without a fit override', async () => { + const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-pane-2' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const pane = createPane(2) + let proposedGrid = { cols: 62, rows: 63 } + pane.terminal.cols = 62 + pane.terminal.rows = 63 + pane.fitAddon = { + ...pane.fitAddon, + fit: vi.fn(() => { + pane.terminal.cols = proposedGrid.cols + pane.terminal.rows = proposedGrid.rows + }), + proposeDimensions: vi.fn(() => proposedGrid) + } as never + + try { + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + proposedGrid = { cols: 65, rows: 63 } + setDriverForPty('pty-pane-2', { kind: 'mobile', clientId: 'phone-1' }) + vi.mocked(pane.fitAddon.fit).mockClear() + transport.resize.mockClear() + vi.mocked(window.api.pty.getSize).mockClear() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b[?2026hcodex redraw frame') + await flushAsyncTicks() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(window.api.pty.getSize).not.toHaveBeenCalled() + expect(transport.resize).not.toHaveBeenCalled() + } finally { + setDriverForPty('pty-pane-2', { kind: 'idle' }) + } + }) + + it('reports desktop geometry without resizing while a mobile-fit override is active', async () => { + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const pane = createPane(2) + const observer = installObservedPane(pane) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => ({ cols: 101, rows: 33 })) + } as never + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + setFitOverride('pty-pane-2', 'mobile-fit', 40, 30) + transport.resize.mockClear() + vi.mocked(window.api.pty.getSize).mockClear() + vi.mocked(window.api.pty.reportGeometry).mockClear() + + observer.trigger() + await flushAsyncTicks() + + expect(window.api.pty.getSize).not.toHaveBeenCalled() + expect(window.api.pty.reportGeometry).toHaveBeenCalledWith('pty-pane-2', 101, 33) + expect(transport.resize).not.toHaveBeenCalled() + } finally { + setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) + observer.restore() + } + }) + + it('updates the claiming desktop xterm before forwarding an observed viewport claim', async () => { + const originalDocument = globalThis.document + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + hasFocus: vi.fn(() => true) + } as unknown as Document + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + queueMicrotask(() => callback(0)) + return 1 + }) + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const pane = createPane(2) + const observer = installObservedPane(pane) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + let proposedGrid = { cols: 120, rows: 40 } + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => proposedGrid) + } as never + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + observer.trigger() + await flushAsyncTicks() + setFitOverride('pty-pane-2', 'remote-desktop-fit', 80, 24) + proposedGrid = { cols: 70, rows: 30 } + vi.mocked(pane.terminal.resize).mockClear() + transport.resize.mockClear() + + observer.trigger() + await flushAsyncTicks() + + expect(pane.terminal.resize).toHaveBeenCalledWith(70, 30) + expect(transport.resize).toHaveBeenCalledTimes(1) + expect(transport.resize).toHaveBeenCalledWith(70, 30, { claim: true }) + } finally { + setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) + observer.restore() + globalThis.document = originalDocument + } + }) + + it('defers a remote-desktop viewport claim until structural replay completes', async () => { + const originalDocument = globalThis.document + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + hasFocus: vi.fn(() => true) + } as unknown as Document + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + queueMicrotask(() => callback(0)) + return 1 + }) + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const { beginTerminalScrollIntentBufferRebuild, endTerminalScrollIntentBufferRebuild } = + await import('@/lib/pane-manager/terminal-scroll-intent-rebuild') + const pane = createPane(2) + const observer = installObservedPane(pane) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + let proposedGrid = { cols: 120, rows: 40 } + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => proposedGrid) + } as never + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + observer.trigger() + await flushAsyncTicks() + setFitOverride('pty-pane-2', 'remote-desktop-fit', 80, 24) + proposedGrid = { cols: 70, rows: 30 } + vi.mocked(pane.terminal.resize).mockClear() + transport.resize.mockClear() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + observer.trigger() + await flushAsyncTicks() + expect(pane.terminal.resize).not.toHaveBeenCalled() + expect(transport.resize).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await flushAsyncTicks() + expect(pane.terminal.resize).toHaveBeenCalledWith(70, 30) + expect(transport.resize).toHaveBeenCalledWith(70, 30, { claim: true }) + } finally { + setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) + observer.restore() + globalThis.document = originalDocument + } + }) + + it('skips observed desktop reassertion while mobile owns the PTY without a fit override', async () => { + const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') + const pane = createPane(2) + const observer = installObservedPane(pane) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const fit = vi.fn() + pane.fitAddon = { + ...pane.fitAddon, + fit, + proposeDimensions: vi.fn(() => ({ cols: 130, rows: 50 })) + } as never + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + setDriverForPty('pty-pane-2', { kind: 'mobile', clientId: 'phone-1' }) + transport.resize.mockClear() + fit.mockClear() + vi.mocked(window.api.pty.getSize).mockClear() + vi.mocked(window.api.pty.reportGeometry).mockClear() + + observer.trigger() + await flushAsyncTicks() + + expect(window.api.pty.getSize).not.toHaveBeenCalled() + expect(window.api.pty.reportGeometry).not.toHaveBeenCalled() + expect(transport.resize).not.toHaveBeenCalled() + expect(fit).not.toHaveBeenCalled() + } finally { + setDriverForPty('pty-pane-2', { kind: 'idle' }) + observer.restore() + } + }) + + it('does NOT re-assert when the PTY already matches xterm (no spurious SIGWINCH)', async () => { + vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 120, rows: 40 }) + const { binding, transport } = await connectResumablePane() + transport.resize.mockClear() + + binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(transport.resize).not.toHaveBeenCalled() + }) + + it('re-asserts when the PTY size is unknown (cannot confirm synced)', async () => { + vi.mocked(window.api.pty.getSize).mockResolvedValue(null) + const { binding, transport } = await connectResumablePane() + transport.resize.mockClear() + + binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) + }) + + it('queues re-asserted resizes while pane resize holds are active', async () => { + const originalCustomEvent = globalThis.CustomEvent + class MockCustomEvent extends Event { + detail: T + + constructor(type: string, init: { detail: T }) { + super(type) + this.detail = init.detail + } + } + globalThis.CustomEvent = MockCustomEvent as unknown as typeof CustomEvent + try { + const { holdPtyResizesForPaneSubtrees, queuePanePtyResizeIfHeld } = + await import('@/lib/pane-manager/pane-pty-resize-hold') + vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 }) + const { binding, transport, pane } = await connectResumablePane() + await flushAsyncTicks() + Object.defineProperty(pane.container, 'classList', { + configurable: true, + value: { contains: (className: string) => className === 'pane' } + }) + Object.defineProperty(pane.container, 'querySelectorAll', { + configurable: true, + value: () => [] + }) + const release = holdPtyResizesForPaneSubtrees([pane.container]) + // Prove the fixture uses the same held pane before reassertion overwrites the queued placeholder size. + expect(queuePanePtyResizeIfHeld(pane.container, 1, 1)).toBe(true) + transport.resize.mockClear() + + binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(transport.resize).not.toHaveBeenCalled() + + release.flush() + + expect(transport.resize).toHaveBeenCalledTimes(1) + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) + } finally { + globalThis.CustomEvent = originalCustomEvent + } + }) + + it.each([true, false])('reasserts a remote grid only when visible (%s)', async (visible) => { + const getSize = vi.mocked(window.api.pty.getSize) + getSize.mockClear() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('remote:env-1@@terminal-2') + transport.getConnectionId.mockReturnValue(null) + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const pane = createPane(2) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The real PanePtyBinding exposes noteVisibilityResume; this fixture narrows it to that method. + const binding = connectPanePty( + pane as never, + manager as never, + deps as never + ) as unknown as { + noteVisibilityResume: () => void + } + transport.resize.mockClear() + + deps.isVisibleRef.current = visible + binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(getSize).not.toHaveBeenCalled() + if (visible) { + expect(transport.resize).toHaveBeenCalledExactlyOnceWith(120, 40, { claim: true }) + } else { + expect(transport.resize).not.toHaveBeenCalled() + } + }) + + it('claims a focused visible remote mirror once when its passive fit hold arrives', async () => { + let documentFocused = true + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + hasFocus: vi.fn(() => documentFocused) + } as unknown as Document + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const { connectPanePty } = await import('./pty-connection') + const ptyId = 'remote:env-1@@terminal-visible' + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: ptyId }, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const pane = createPane(2) + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => ({ cols: 132, rows: 42 })) + } as never + const binding = connectPanePty(pane as never, createManager(2) as never, deps as never) + await flushAsyncTicks() + transport.claimViewport.mockClear() + + setFitOverride(ptyId, 'remote-desktop-fit', 10, 4) + + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + setFitOverride(ptyId, 'desktop-fit', 132, 42) + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + documentFocused = false + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + documentFocused = true + binding.reassertPtySizeAfterWindowWake() + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + binding.dispose() + }) + + it('does NOT re-assert while a mobile-fit override parks the PTY at phone dims', async () => { + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 }) + const { binding, transport } = await connectResumablePane() + // Park the PTY at phone dims — desktop re-assert must be suppressed. + setFitOverride('pty-pane-2', 'mobile-fit', 40, 30) + transport.resize.mockClear() + + binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(transport.resize).not.toHaveBeenCalled() + setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) + }) + + it('does NOT forward when the pane is hidden again before getSize resolves (stale hop)', async () => { + // Stale getSize resolving after re-hide must not emit a hidden-tab SIGWINCH (which resets alt-screen TUIs). + let resolveSize: (v: { cols: number; rows: number } | null) => void = () => {} + vi.mocked(window.api.pty.getSize).mockImplementation( + () => + new Promise((resolve) => { + resolveSize = resolve + }) + ) + const { binding, transport, deps } = await connectResumablePane() + transport.resize.mockClear() + + binding.noteVisibilityResume() + // Pane is hidden again while the size query is still in flight. + deps.isVisibleRef.current = false + resolveSize({ cols: 80, rows: 24 }) // drift — would re-assert if visible + await flushAsyncTicks() + + expect(transport.resize).not.toHaveBeenCalled() + }) + + it('coalesces overlapping resumes into a single size query (re-entrancy guard)', async () => { + const getSize = vi.mocked(window.api.pty.getSize) + getSize.mockClear() + let resolveSize: (v: { cols: number; rows: number } | null) => void = () => {} + getSize.mockImplementation( + () => + new Promise((resolve) => { + resolveSize = resolve + }) + ) + const { binding } = await connectResumablePane() + getSize.mockClear() + + // Two rapid resumes before the first query resolves → only one query. + binding.noteVisibilityResume() + binding.noteVisibilityResume() + expect(getSize).toHaveBeenCalledTimes(1) + resolveSize({ cols: 120, rows: 40 }) + await flushAsyncTicks() + }) + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts deleted file mode 100644 index f5fe39b9bae..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts +++ /dev/null @@ -1,789 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - LEAF_2, - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - describe('PTY size re-assert on visibility resume', () => { - // Why: a resize dropped while hidden leaves xterm and the PTY diverged, and dedupe hides it; resume re-asserts on real drift. - async function connectResumablePane(depsOverrides: Record = {}): Promise<{ - binding: { noteVisibilityResume: () => void } - transport: MockTransport - deps: ReturnType - pane: ReturnType - }> { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }, - ...depsOverrides - }) - const pane = createPane(2) - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - noteVisibilityResume: () => void - } - return { binding, transport, deps, pane } - } - - function installObservedPane(pane: ReturnType): { - trigger: () => void - restore: () => void - } { - const originalResizeObserver = globalThis.ResizeObserver - const originalElement = globalThis.Element - const hadResizeObserver = 'ResizeObserver' in globalThis - const hadElement = 'Element' in globalThis - type ResizeObserverCallbackLike = ConstructorParameters[0] - class MockElement extends EventTarget { - dataset: Record = {} - classList = { contains: (className: string) => className === 'pane' } - - querySelectorAll(): MockElement[] { - return [] - } - } - class MockResizeObserver { - static instances: MockResizeObserver[] = [] - observe = vi.fn() - disconnect = vi.fn() - - constructor(private readonly callback: ResizeObserverCallbackLike) { - MockResizeObserver.instances.push(this) - } - - trigger(): void { - this.callback([], this as never) - } - } - - globalThis.Element = MockElement as never - globalThis.ResizeObserver = MockResizeObserver as never - pane.container = new MockElement() as unknown as HTMLElement - - return { - trigger: () => MockResizeObserver.instances[0]?.trigger(), - restore: () => { - if (hadResizeObserver) { - globalThis.ResizeObserver = originalResizeObserver - } else { - Reflect.deleteProperty(globalThis, 'ResizeObserver') - } - if (hadElement) { - globalThis.Element = originalElement - } else { - Reflect.deleteProperty(globalThis, 'Element') - } - } - } - } - - it('re-asserts the current size when the PTY drifted from xterm', async () => { - vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 }) - const { binding, transport } = await connectResumablePane() - transport.resize.mockClear() - - binding.noteVisibilityResume() - await flushAsyncTicks() - - // xterm is 120x40 (createPane default), PTY reports 80x24 → re-assert. - expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) - }) - - it('does not fit during visibility-resume reassertion', async () => { - vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 }) - const { binding, transport, pane } = await connectResumablePane() - const fit = vi.fn(() => { - pane.terminal.cols = 132 - pane.terminal.rows = 40 - }) - pane.fitAddon = { - ...pane.fitAddon, - fit, - proposeDimensions: vi.fn(() => ({ cols: 132, rows: 40 })) - } as never - Object.defineProperty(pane.container, 'getBoundingClientRect', { - configurable: true, - value: () => ({ width: 1130, height: 688 }) - }) - transport.resize.mockClear() - - binding.noteVisibilityResume() - await flushAsyncTicks() - - expect(fit).not.toHaveBeenCalled() - expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) - }) - - it('re-asserts after observed pane geometry changes while visible', async () => { - const pane = createPane(2) - const observer = installObservedPane(pane) - try { - vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 200, rows: 40 }) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - pane.terminal.cols = 82 - pane.terminal.rows = 40 - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - transport.resize.mockClear() - vi.mocked(window.api.pty.getSize).mockClear() - - observer.trigger() - await flushAsyncTicks() - - expect(window.api.pty.getSize).toHaveBeenCalledWith('pty-pane-2') - expect(transport.resize).toHaveBeenCalledWith(82, 40, { claim: true }) - } finally { - observer.restore() - } - }) - - it('repairs stale xterm grid drift on foreground output even without a pane resize', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-pane-2' - } - ) - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - const pane = createPane(2) - let proposedGrid = { cols: 62, rows: 63 } - pane.terminal.cols = 62 - pane.terminal.rows = 63 - pane.fitAddon = { - ...pane.fitAddon, - fit: vi.fn(() => { - pane.terminal.cols = proposedGrid.cols - pane.terminal.rows = proposedGrid.rows - }), - proposeDimensions: vi.fn(() => proposedGrid) - } as never - vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 62, rows: 63 }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - proposedGrid = { cols: 65, rows: 63 } - vi.mocked(pane.fitAddon.fit).mockClear() - transport.resize.mockClear() - vi.mocked(window.api.pty.getSize).mockClear() - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('\x1b[?2026hcodex redraw frame') - await flushAsyncTicks() - - expect(pane.fitAddon.fit).toHaveBeenCalled() - expect(window.api.pty.getSize).toHaveBeenCalledWith('pty-pane-2') - expect(transport.resize).toHaveBeenCalledWith(65, 63, { claim: true }) - }) - - it('skips foreground grid drift repair while mobile owns the PTY without a fit override', async () => { - const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-pane-2' - } - ) - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - const pane = createPane(2) - let proposedGrid = { cols: 62, rows: 63 } - pane.terminal.cols = 62 - pane.terminal.rows = 63 - pane.fitAddon = { - ...pane.fitAddon, - fit: vi.fn(() => { - pane.terminal.cols = proposedGrid.cols - pane.terminal.rows = proposedGrid.rows - }), - proposeDimensions: vi.fn(() => proposedGrid) - } as never - - try { - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - proposedGrid = { cols: 65, rows: 63 } - setDriverForPty('pty-pane-2', { kind: 'mobile', clientId: 'phone-1' }) - vi.mocked(pane.fitAddon.fit).mockClear() - transport.resize.mockClear() - vi.mocked(window.api.pty.getSize).mockClear() - expect(capturedDataCallback.current).not.toBeNull() - - capturedDataCallback.current?.('\x1b[?2026hcodex redraw frame') - await flushAsyncTicks() - - expect(pane.fitAddon.fit).not.toHaveBeenCalled() - expect(window.api.pty.getSize).not.toHaveBeenCalled() - expect(transport.resize).not.toHaveBeenCalled() - } finally { - setDriverForPty('pty-pane-2', { kind: 'idle' }) - } - }) - - it('reports desktop geometry without resizing while a mobile-fit override is active', async () => { - const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') - const pane = createPane(2) - const observer = installObservedPane(pane) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - pane.fitAddon = { - ...pane.fitAddon, - proposeDimensions: vi.fn(() => ({ cols: 101, rows: 33 })) - } as never - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - setFitOverride('pty-pane-2', 'mobile-fit', 40, 30) - transport.resize.mockClear() - vi.mocked(window.api.pty.getSize).mockClear() - vi.mocked(window.api.pty.reportGeometry).mockClear() - - observer.trigger() - await flushAsyncTicks() - - expect(window.api.pty.getSize).not.toHaveBeenCalled() - expect(window.api.pty.reportGeometry).toHaveBeenCalledWith('pty-pane-2', 101, 33) - expect(transport.resize).not.toHaveBeenCalled() - } finally { - setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) - observer.restore() - } - }) - - it('updates the claiming desktop xterm before forwarding an observed viewport claim', async () => { - const originalDocument = globalThis.document - ;(globalThis as { document?: Document }).document = { - visibilityState: 'visible', - hasFocus: vi.fn(() => true) - } as unknown as Document - globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - queueMicrotask(() => callback(0)) - return 1 - }) - const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') - const pane = createPane(2) - const observer = installObservedPane(pane) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - let proposedGrid = { cols: 120, rows: 40 } - pane.fitAddon = { - ...pane.fitAddon, - proposeDimensions: vi.fn(() => proposedGrid) - } as never - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - observer.trigger() - await flushAsyncTicks() - setFitOverride('pty-pane-2', 'remote-desktop-fit', 80, 24) - proposedGrid = { cols: 70, rows: 30 } - vi.mocked(pane.terminal.resize).mockClear() - transport.resize.mockClear() - - observer.trigger() - await flushAsyncTicks() - - expect(pane.terminal.resize).toHaveBeenCalledWith(70, 30) - expect(transport.resize).toHaveBeenCalledTimes(1) - expect(transport.resize).toHaveBeenCalledWith(70, 30, { claim: true }) - } finally { - setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) - observer.restore() - globalThis.document = originalDocument - } - }) - - it('defers a remote-desktop viewport claim until structural replay completes', async () => { - const originalDocument = globalThis.document - ;(globalThis as { document?: Document }).document = { - visibilityState: 'visible', - hasFocus: vi.fn(() => true) - } as unknown as Document - globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - queueMicrotask(() => callback(0)) - return 1 - }) - const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') - const { beginTerminalScrollIntentBufferRebuild, endTerminalScrollIntentBufferRebuild } = - await import('@/lib/pane-manager/terminal-scroll-intent-rebuild') - const pane = createPane(2) - const observer = installObservedPane(pane) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - let proposedGrid = { cols: 120, rows: 40 } - pane.fitAddon = { - ...pane.fitAddon, - proposeDimensions: vi.fn(() => proposedGrid) - } as never - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - observer.trigger() - await flushAsyncTicks() - setFitOverride('pty-pane-2', 'remote-desktop-fit', 80, 24) - proposedGrid = { cols: 70, rows: 30 } - vi.mocked(pane.terminal.resize).mockClear() - transport.resize.mockClear() - beginTerminalScrollIntentBufferRebuild(pane.terminal) - - observer.trigger() - await flushAsyncTicks() - expect(pane.terminal.resize).not.toHaveBeenCalled() - expect(transport.resize).not.toHaveBeenCalled() - - endTerminalScrollIntentBufferRebuild(pane.terminal) - await flushAsyncTicks() - expect(pane.terminal.resize).toHaveBeenCalledWith(70, 30) - expect(transport.resize).toHaveBeenCalledWith(70, 30, { claim: true }) - } finally { - setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) - observer.restore() - globalThis.document = originalDocument - } - }) - - it('skips observed desktop reassertion while mobile owns the PTY without a fit override', async () => { - const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') - const pane = createPane(2) - const observer = installObservedPane(pane) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-pane-2') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - const fit = vi.fn() - pane.fitAddon = { - ...pane.fitAddon, - fit, - proposeDimensions: vi.fn(() => ({ cols: 130, rows: 50 })) - } as never - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - setDriverForPty('pty-pane-2', { kind: 'mobile', clientId: 'phone-1' }) - transport.resize.mockClear() - fit.mockClear() - vi.mocked(window.api.pty.getSize).mockClear() - vi.mocked(window.api.pty.reportGeometry).mockClear() - - observer.trigger() - await flushAsyncTicks() - - expect(window.api.pty.getSize).not.toHaveBeenCalled() - expect(window.api.pty.reportGeometry).not.toHaveBeenCalled() - expect(transport.resize).not.toHaveBeenCalled() - expect(fit).not.toHaveBeenCalled() - } finally { - setDriverForPty('pty-pane-2', { kind: 'idle' }) - observer.restore() - } - }) - - it('does NOT re-assert when the PTY already matches xterm (no spurious SIGWINCH)', async () => { - vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 120, rows: 40 }) - const { binding, transport } = await connectResumablePane() - transport.resize.mockClear() - - binding.noteVisibilityResume() - await flushAsyncTicks() - - expect(transport.resize).not.toHaveBeenCalled() - }) - - it('re-asserts when the PTY size is unknown (cannot confirm synced)', async () => { - vi.mocked(window.api.pty.getSize).mockResolvedValue(null) - const { binding, transport } = await connectResumablePane() - transport.resize.mockClear() - - binding.noteVisibilityResume() - await flushAsyncTicks() - - expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) - }) - - it('queues re-asserted resizes while pane resize holds are active', async () => { - const originalCustomEvent = globalThis.CustomEvent - class MockCustomEvent extends Event { - detail: T - - constructor(type: string, init: { detail: T }) { - super(type) - this.detail = init.detail - } - } - globalThis.CustomEvent = MockCustomEvent as unknown as typeof CustomEvent - try { - const { holdPtyResizesForPaneSubtrees, queuePanePtyResizeIfHeld } = - await import('@/lib/pane-manager/pane-pty-resize-hold') - vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 }) - const { binding, transport, pane } = await connectResumablePane() - await flushAsyncTicks() - Object.defineProperty(pane.container, 'classList', { - configurable: true, - value: { contains: (className: string) => className === 'pane' } - }) - Object.defineProperty(pane.container, 'querySelectorAll', { - configurable: true, - value: () => [] - }) - const release = holdPtyResizesForPaneSubtrees([pane.container]) - // Prove the fixture uses the same held pane before reassertion overwrites the queued placeholder size. - expect(queuePanePtyResizeIfHeld(pane.container, 1, 1)).toBe(true) - transport.resize.mockClear() - - binding.noteVisibilityResume() - await flushAsyncTicks() - - expect(transport.resize).not.toHaveBeenCalled() - - release.flush() - - expect(transport.resize).toHaveBeenCalledTimes(1) - expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) - } finally { - globalThis.CustomEvent = originalCustomEvent - } - }) - - it.each([true, false])('reasserts a remote grid only when visible (%s)', async (visible) => { - const getSize = vi.mocked(window.api.pty.getSize) - getSize.mockClear() - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('remote:env-1@@terminal-2') - transport.getConnectionId.mockReturnValue(null) - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - const pane = createPane(2) - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - noteVisibilityResume: () => void - } - transport.resize.mockClear() - - deps.isVisibleRef.current = visible - binding.noteVisibilityResume() - await flushAsyncTicks() - - expect(getSize).not.toHaveBeenCalled() - if (visible) { - expect(transport.resize).toHaveBeenCalledExactlyOnceWith(120, 40, { claim: true }) - } else { - expect(transport.resize).not.toHaveBeenCalled() - } - }) - - it('claims a focused visible remote mirror once when its passive fit hold arrives', async () => { - let documentFocused = true - ;(globalThis as { document?: Document }).document = { - visibilityState: 'visible', - hasFocus: vi.fn(() => documentFocused) - } as unknown as Document - const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') - const { connectPanePty } = await import('./pty-connection') - const ptyId = 'remote:env-1@@terminal-visible' - const transport = createMockTransport(ptyId) - transportFactoryQueue.push(transport) - const deps = createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { [LEAF_2]: ptyId }, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - const pane = createPane(2) - pane.fitAddon = { - ...pane.fitAddon, - proposeDimensions: vi.fn(() => ({ cols: 132, rows: 42 })) - } as never - const binding = connectPanePty(pane as never, createManager(2) as never, deps as never) - await flushAsyncTicks() - transport.claimViewport.mockClear() - - setFitOverride(ptyId, 'remote-desktop-fit', 10, 4) - - expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) - setFitOverride(ptyId, 'desktop-fit', 132, 42) - transport.claimViewport.mockClear() - setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) - expect(transport.claimViewport).not.toHaveBeenCalled() - - deps.isVisibleRef.current = false - binding.syncProcessTracking() - deps.isVisibleRef.current = true - binding.noteVisibilityResume() - expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) - - setFitOverride(ptyId, 'desktop-fit', 132, 42) - documentFocused = false - deps.isVisibleRef.current = false - binding.syncProcessTracking() - deps.isVisibleRef.current = true - binding.noteVisibilityResume() - transport.claimViewport.mockClear() - setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) - expect(transport.claimViewport).not.toHaveBeenCalled() - - documentFocused = true - binding.reassertPtySizeAfterWindowWake() - expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) - - setFitOverride(ptyId, 'desktop-fit', 132, 42) - deps.isVisibleRef.current = false - binding.syncProcessTracking() - deps.isVisibleRef.current = true - binding.noteVisibilityResume() - transport.claimViewport.mockClear() - setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) - expect(transport.claimViewport).not.toHaveBeenCalled() - - setFitOverride(ptyId, 'desktop-fit', 132, 42) - binding.dispose() - }) - - it('does NOT re-assert while a mobile-fit override parks the PTY at phone dims', async () => { - const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') - vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 }) - const { binding, transport } = await connectResumablePane() - // Park the PTY at phone dims — desktop re-assert must be suppressed. - setFitOverride('pty-pane-2', 'mobile-fit', 40, 30) - transport.resize.mockClear() - - binding.noteVisibilityResume() - await flushAsyncTicks() - - expect(transport.resize).not.toHaveBeenCalled() - setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) - }) - - it('does NOT forward when the pane is hidden again before getSize resolves (stale hop)', async () => { - // Stale getSize resolving after re-hide must not emit a hidden-tab SIGWINCH (which resets alt-screen TUIs). - let resolveSize: (v: { cols: number; rows: number } | null) => void = () => {} - vi.mocked(window.api.pty.getSize).mockImplementation( - () => - new Promise((resolve) => { - resolveSize = resolve - }) - ) - const { binding, transport, deps } = await connectResumablePane() - transport.resize.mockClear() - - binding.noteVisibilityResume() - // Pane is hidden again while the size query is still in flight. - deps.isVisibleRef.current = false - resolveSize({ cols: 80, rows: 24 }) // drift — would re-assert if visible - await flushAsyncTicks() - - expect(transport.resize).not.toHaveBeenCalled() - }) - - it('coalesces overlapping resumes into a single size query (re-entrancy guard)', async () => { - const getSize = vi.mocked(window.api.pty.getSize) - getSize.mockClear() - let resolveSize: (v: { cols: number; rows: number } | null) => void = () => {} - getSize.mockImplementation( - () => - new Promise((resolve) => { - resolveSize = resolve - }) - ) - const { binding } = await connectResumablePane() - getSize.mockClear() - - // Two rapid resumes before the first query resolves → only one query. - binding.noteVisibilityResume() - binding.noteVisibilityResume() - expect(getSize).toHaveBeenCalledTimes(1) - resolveSize({ cols: 120, rows: 40 }) - await flushAsyncTicks() - }) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.spec.ts new file mode 100644 index 00000000000..9d23759b8cd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.spec.ts @@ -0,0 +1,507 @@ +import { bindPtyConnectionTestMocks, ptyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + temporarilySetNavigatorUserAgent, + sendTerminalInputThroughPane +} from './pty-connection-test-dom' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionWindowsCjkRepaintTests(): void { + const { resetAndRefreshAllTerminalWebglAtlases, scheduleTerminalWebglAtlasRecovery } = + ptyConnectionTestMocks + + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + function expectNoGlobalAtlasRecovery(): void { + // Why: the removed output path requested recovery 200ms before its global reset ran. + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('does not schedule WebGL atlas recovery for ordinary foreground shell rewrites', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\r\x1b[Korca % npm test') + + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expectNoGlobalAtlasRecovery() + } finally { + restoreNavigator() + } + }) + + it('does not schedule WebGL atlas recovery for plain synchronized foreground frames', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + let parseCallback: (() => void) | undefined + pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?2026hplain claude frame\x1b[?2026l') + parseCallback?.() + expectNoGlobalAtlasRecovery() + } finally { + restoreNavigator() + } + }) + + it('forces a viewport refresh when foreground background SGR is split across PTY chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + + connectPanePty(pane as never, manager as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[48') + expect(refresh).not.toHaveBeenCalled() + + capturedDataCallback.current?.(';2;52;52;52m codex block text \x1b[0m\r\n') + + expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + }) + + it('forces a viewport refresh when the foreground CSI introducer is split across PTY chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + + connectPanePty(pane as never, manager as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b') + expect(refresh).not.toHaveBeenCalled() + + capturedDataCallback.current?.('[48;2;52;52;52m codex block text \x1b[0m\r\n') + + expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + }) + + it('does not keep forcing viewport refresh after completed background redraws', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.( + '\x1b[2J\x1b[H\x1b[48;2;52;52;52m codex block text \x1b[0m\r\n' + ) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + + refresh.mockClear() + capturedDataCallback.current?.('plain follow-up output\r\n') + + expect(refresh).not.toHaveBeenCalled() + }) + + it('forces a viewport refresh for native Windows CJK foreground output after terminal input', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + sendTerminalInputThroughPane(pane, '已经安装完成,软件已更新后重启。') + + capturedDataCallback.current?.('已经安装完成,软件已更新后重启。') + + expect(refresh).toHaveBeenCalledWith(0, 39, true) + } finally { + restoreNavigator() + } + }) + + it('forces the native Windows CJK repaint path for foreground agent output without recent terminal input', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('已经安装完成,软件已更新后重启。') + + expect(refresh).toHaveBeenCalledWith(0, 39, true) + } finally { + restoreNavigator() + } + }) + + it('does not force renderer-risk repaint for ordinary non-ASCII output', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + sendTerminalInputThroughPane(pane, 'abc 123 ✓') + + capturedDataCallback.current?.('abc 123 ✓') + + expect(refresh).not.toHaveBeenCalled() + } finally { + restoreNavigator() + } + }) + + it('does not repaint ordinary non-ASCII output on non-Windows clients', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('abc 123 ✓') + + expect(refresh).not.toHaveBeenCalled() + } finally { + restoreNavigator() + } + }) + + it('schedules a follow-up repaint for Claude-style in-place prompt redraws on native Windows', async () => { + // Why: issue #5656/#5653 — native Windows ConPTY paints Claude's in-place prompt redraw one frame late; needs a follow-up next-frame repaint. + const restoreNavigator = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // User typed into the prompt; Claude redraws the input line in place. + capturedDataCallback.current?.('\r\x1b[3Gzzzx\x1b[K') + + // One synchronous settle refresh + one follow-up next-frame refresh. + expect(refresh).toHaveBeenCalledTimes(2) + expect(refresh).toHaveBeenNthCalledWith(1, 0, 39, true) + expect(refresh).toHaveBeenNthCalledWith(2, 0, 39, true) + } finally { + restoreNavigator() + } + }) + + it('does not schedule a follow-up repaint for the Claude redraw pattern on non-Windows clients', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // CR redraw still forces one synchronous refresh, but no native-Windows follow-up repaint is scheduled. + capturedDataCallback.current?.('\r\x1b[3Gzzzx\x1b[K') + + expect(refresh).toHaveBeenCalledTimes(1) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + } finally { + restoreNavigator() + } + }) + + it('coalesces forced foreground refreshes when WebGL is live', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const synchronousRefresh = vi.fn() + const debouncedRefresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof synchronousRefresh } + refresh: typeof debouncedRefresh + } + terminal._core = { refresh: synchronousRefresh } + terminal.refresh = debouncedRefresh + terminal.write = vi.fn((_data: string, callback?: () => void) => callback?.()) + const manager = createManager(1) + manager.hasWebglRenderer.mockReturnValue(true) + + connectPanePty(pane as never, manager as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\r\x1b[3Gzzzx\x1b[K') + + expect(debouncedRefresh).toHaveBeenCalledWith(0, 39) + expect(synchronousRefresh).not.toHaveBeenCalled() + } finally { + restoreNavigator() + } + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.test.ts deleted file mode 100644 index 09dbb786479..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.test.ts +++ /dev/null @@ -1,587 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from './pty-connection-test-async' -import { - temporarilySetNavigatorUserAgent, - sendTerminalInputThroughPane -} from './pty-connection-test-dom' -import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -function expectNoGlobalAtlasRecovery(): void { - // Why: the removed output path requested recovery 200ms before its global reset ran. - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() - expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('does not schedule WebGL atlas recovery for ordinary foreground shell rewrites', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - let parseCallback: (() => void) | undefined - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - parseCallback = callback - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\r\x1b[Korca % npm test') - - parseCallback?.() - expect(refresh).toHaveBeenCalledWith(0, 39, true) - expectNoGlobalAtlasRecovery() - } finally { - restoreNavigator() - } - }) - - it('does not schedule WebGL atlas recovery for plain synchronized foreground frames', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - let parseCallback: (() => void) | undefined - pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { - parseCallback = callback - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[?2026hplain claude frame\x1b[?2026l') - parseCallback?.() - expectNoGlobalAtlasRecovery() - } finally { - restoreNavigator() - } - }) - - it('forces a viewport refresh when foreground background SGR is split across PTY chunks', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - - connectPanePty(pane as never, manager as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[48') - expect(refresh).not.toHaveBeenCalled() - - capturedDataCallback.current?.(';2;52;52;52m codex block text \x1b[0m\r\n') - - expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() - expect(refresh).toHaveBeenCalledWith(0, 39, true) - }) - - it('forces a viewport refresh when the foreground CSI introducer is split across PTY chunks', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - - connectPanePty(pane as never, manager as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b') - expect(refresh).not.toHaveBeenCalled() - - capturedDataCallback.current?.('[48;2;52;52;52m codex block text \x1b[0m\r\n') - - expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() - expect(refresh).toHaveBeenCalledWith(0, 39, true) - }) - - it('does not keep forcing viewport refresh after completed background redraws', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\x1b[2J\x1b[H\x1b[48;2;52;52;52m codex block text \x1b[0m\r\n') - expect(refresh).toHaveBeenCalledWith(0, 39, true) - - refresh.mockClear() - capturedDataCallback.current?.('plain follow-up output\r\n') - - expect(refresh).not.toHaveBeenCalled() - }) - - it('forces a viewport refresh for native Windows CJK foreground output after terminal input', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - sendTerminalInputThroughPane(pane, '已经安装完成,软件已更新后重启。') - - capturedDataCallback.current?.('已经安装完成,软件已更新后重启。') - - expect(refresh).toHaveBeenCalledWith(0, 39, true) - } finally { - restoreNavigator() - } - }) - - it('forces the native Windows CJK repaint path for foreground agent output without recent terminal input', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('已经安装完成,软件已更新后重启。') - - expect(refresh).toHaveBeenCalledWith(0, 39, true) - } finally { - restoreNavigator() - } - }) - - it('does not force renderer-risk repaint for ordinary non-ASCII output', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - sendTerminalInputThroughPane(pane, 'abc 123 ✓') - - capturedDataCallback.current?.('abc 123 ✓') - - expect(refresh).not.toHaveBeenCalled() - } finally { - restoreNavigator() - } - }) - - it('does not repaint ordinary non-ASCII output on non-Windows clients', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('abc 123 ✓') - - expect(refresh).not.toHaveBeenCalled() - } finally { - restoreNavigator() - } - }) - - it('schedules a follow-up repaint for Claude-style in-place prompt redraws on native Windows', async () => { - // Why: issue #5656/#5653 — native Windows ConPTY paints Claude's in-place prompt redraw one frame late; needs a follow-up next-frame repaint. - const restoreNavigator = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - // User typed into the prompt; Claude redraws the input line in place. - capturedDataCallback.current?.('\r\x1b[3Gzzzx\x1b[K') - - // One synchronous settle refresh + one follow-up next-frame refresh. - expect(refresh).toHaveBeenCalledTimes(2) - expect(refresh).toHaveBeenNthCalledWith(1, 0, 39, true) - expect(refresh).toHaveBeenNthCalledWith(2, 0, 39, true) - } finally { - restoreNavigator() - } - }) - - it('does not schedule a follow-up repaint for the Claude redraw pattern on non-Windows clients', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const refresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof refresh } - } - terminal._core = { refresh } - terminal.write = vi.fn((_data: string, callback?: () => void) => { - callback?.() - }) - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - await flushAsyncTicks(6) - - // CR redraw still forces one synchronous refresh, but no native-Windows follow-up repaint is scheduled. - capturedDataCallback.current?.('\r\x1b[3Gzzzx\x1b[K') - - expect(refresh).toHaveBeenCalledTimes(1) - expect(refresh).toHaveBeenCalledWith(0, 39, true) - } finally { - restoreNavigator() - } - }) - - it('coalesces forced foreground refreshes when WebGL is live', async () => { - const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') - try { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - } - ) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const synchronousRefresh = vi.fn() - const debouncedRefresh = vi.fn() - const terminal = pane.terminal as typeof pane.terminal & { - _core?: { refresh: typeof synchronousRefresh } - refresh: typeof debouncedRefresh - } - terminal._core = { refresh: synchronousRefresh } - terminal.refresh = debouncedRefresh - terminal.write = vi.fn((_data: string, callback?: () => void) => callback?.()) - const manager = createManager(1) - manager.hasWebglRenderer.mockReturnValue(true) - - connectPanePty(pane as never, manager as never, createDeps() as never) - await flushAsyncTicks(6) - - capturedDataCallback.current?.('\r\x1b[3Gzzzx\x1b[K') - - expect(debouncedRefresh).toHaveBeenCalledWith(0, 39) - expect(synchronousRefresh).not.toHaveBeenCalled() - } finally { - restoreNavigator() - } - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.spec.ts new file mode 100644 index 00000000000..397aef7c4de --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.spec.ts @@ -0,0 +1,639 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + RESET_KITTY_KEYBOARD_PROTOCOL, + RESET_TERMINAL_CURSOR_STYLE +} from '../../../../shared/terminal-mode-reset-profiles' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { + temporarilySetNavigatorUserAgent, + sendTerminalInputThroughPane +} from './pty-connection-test-dom' +import { + LEAF_1, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +export function registerPtyConnectionWindowsKeyboardResetTests(): void { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) + } + + function notifyStoreSubscribers(): void { + for (const listener of storeSubscribers.slice()) { + listener(mockStoreState) + } + } + + describe('connectPanePty', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('resets renderer cursor style when an agent becomes idle', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!idleHandler) { + throw new Error('Expected onAgentBecameIdle to be registered') + } + + idleHandler('* Codex done') + + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + }) + + it('keeps kitty keyboard state when a native Windows agent becomes idle', async () => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!idleHandler) { + throw new Error('Expected onAgentBecameIdle to be registered') + } + + idleHandler('* Codex done') + + // Why: a finished turn is not a dead app; its kitty flags stay until the host sees it exit. + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, + expect.any(Function) + ) + } finally { + restoreUserAgent() + } + }) + + it.each([ + { + name: 'WSL', + configure: (): void => { + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-1', ptyId: null, shellOverride: 'wsl.exe' }] + } + } + }, + { + name: 'remote runtime', + configure: (): void => { + mockStoreState.repos = [ + { + id: 'repo1', + connectionId: null, + displayName: 'orca', + executionHostId: 'runtime:owner-runtime' + } + ] + } + } + ])( + 'keeps $name focus reports and idle reset remote-safe on Windows clients', + async ({ configure }) => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + configure() + const pane = createPane(1) + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!idleHandler) { + throw new Error('Expected onAgentBecameIdle to be registered') + } + idleHandler('* Codex done') + + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + transport.sendInput.mockClear() + sendTerminalInputThroughPane(pane, '\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') + } finally { + restoreUserAgent() + } + } + ) + + it('resets cursor style when native Windows hook status reaches done', async () => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'ship it', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'codex', + paneKey, + stateHistory: [] + } + notifyStoreSubscribers() + expect(pane.terminal.write).not.toHaveBeenCalled() + + const doneStatus = { + state: 'done', + prompt: 'ship it', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'codex', + paneKey, + stateHistory: [] + } + mockStoreState.agentStatusByPaneKey[paneKey] = doneStatus + notifyStoreSubscribers() + + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + } finally { + restoreUserAgent() + } + }) + + it('resets cursor style when a batched done→working→done burst lands as one publication', async () => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const paneKey = makePaneKey('tab-1', LEAF_1) + const firstDoneAt = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'first turn', + updatedAt: firstDoneAt, + stateStartedAt: firstDoneAt, + agentType: 'codex', + paneKey, + stateHistory: [{ state: 'working', prompt: 'first turn', startedAt: firstDoneAt - 5_000 }] + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + expect(pane.terminal.write).not.toHaveBeenCalled() + + // The burst's intermediate `working` never publishes, so the entry stays `done` end-to-end; + // only the stateHistory row proves a second turn ran and re-armed the kitty protocol. + const secondDoneAt = firstDoneAt + 10_000 + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'second turn', + updatedAt: secondDoneAt, + stateStartedAt: secondDoneAt, + agentType: 'codex', + paneKey, + stateHistory: [ + { state: 'working', prompt: 'first turn', startedAt: firstDoneAt - 5_000 }, + { state: 'done', prompt: 'first turn', startedAt: firstDoneAt }, + { state: 'working', prompt: 'second turn', startedAt: secondDoneAt - 2_000 } + ] + } + notifyStoreSubscribers() + + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + } finally { + restoreUserAgent() + } + }) + + it('resets cursor style when a batched burst ends on working after a completed turn', async () => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const paneKey = makePaneKey('tab-1', LEAF_1) + const startedAt = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'ship it', + updatedAt: startedAt, + stateStartedAt: startedAt, + agentType: 'codex', + paneKey, + stateHistory: [] + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + notifyStoreSubscribers() + expect(pane.terminal.write).not.toHaveBeenCalled() + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'follow-up', + updatedAt: startedAt + 8_000, + stateStartedAt: startedAt + 8_000, + agentType: 'codex', + paneKey, + stateHistory: [ + { state: 'working', prompt: 'ship it', startedAt }, + { state: 'done', prompt: 'ship it', startedAt: startedAt + 4_000 } + ] + } + notifyStoreSubscribers() + + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + } finally { + restoreUserAgent() + } + }) + + it('keeps native Windows same-turn done repaints from re-resetting cursor style', async () => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const paneKey = makePaneKey('tab-1', LEAF_1) + const doneAt = Date.now() + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'ship it', + updatedAt: doneAt, + stateStartedAt: doneAt, + agentType: 'codex', + paneKey, + stateHistory: [] + } + notifyStoreSubscribers() + const writesAfterDone = (pane.terminal.write as ReturnType).mock.calls.length + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'ship it', + updatedAt: doneAt + 1_000, + stateStartedAt: doneAt, + agentType: 'codex', + paneKey, + stateHistory: [] + } + notifyStoreSubscribers() + + expect((pane.terminal.write as ReturnType).mock.calls.length).toBe( + writesAfterDone + ) + } finally { + restoreUserAgent() + } + }) + + it('drops native Windows Codex focus reports while a history resume is idle', async () => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + pane.terminal.modes.sendFocusMode = true + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + startup: { + command: "codex 'resume' 'codex-session-1'", + launchAgent: 'codex', + telemetry: { + agent_kind: 'codex', + launch_source: 'sidebar', + request_kind: 'resume' + } + } + }) as never + ) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBeUndefined() + transport.sendInput.mockClear() + + sendTerminalInputThroughPane(pane, '\x1b[O') + sendTerminalInputThroughPane(pane, '\x1b[I') + expect(transport.sendInput).not.toHaveBeenCalled() + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'continue the resumed session', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'codex', + paneKey, + stateHistory: [] + } + notifyStoreSubscribers() + + sendTerminalInputThroughPane(pane, '\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') + } finally { + restoreUserAgent() + } + }) + + it.each([ + { + name: 'Claude history resume', + launchAgent: 'claude', + agentKind: 'claude', + requestKind: 'resume' + }, + { + name: 'fresh Codex launch', + launchAgent: 'codex', + agentKind: 'codex', + requestKind: 'new' + } + ])( + 'keeps focus reports enabled for a native Windows $name', + async ({ launchAgent, agentKind, requestKind }) => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const pane = createPane(1) + pane.terminal.modes.sendFocusMode = true + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + startup: { + command: `${launchAgent} session-command`, + launchAgent, + telemetry: { + agent_kind: agentKind, + launch_source: 'sidebar', + request_kind: requestKind + } + } + }) as never + ) + + transport.sendInput.mockClear() + sendTerminalInputThroughPane(pane, '\x1b[O') + sendTerminalInputThroughPane(pane, '\x1b[I') + + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O', 'query-reply') + expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I', 'query-reply') + } finally { + restoreUserAgent() + } + } + ) + + it('drops only focus reports while native Windows Codex is done and resumes them when working', async () => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + pane.terminal.modes.sendFocusMode = true + const doneStatus = { + state: 'done', + prompt: 'ship it', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'codex', + paneKey, + stateHistory: [] + } + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + mockStoreState.agentStatusByPaneKey[paneKey] = doneStatus + notifyStoreSubscribers() + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + idleHandler?.('* Codex done') + transport.sendInput.mockClear() + + sendTerminalInputThroughPane(pane, '\x1b[O') + sendTerminalInputThroughPane(pane, '\x1b[I') + expect(transport.sendInput).not.toHaveBeenCalled() + + sendTerminalInputThroughPane(pane, '\x7f') + sendTerminalInputThroughPane(pane, 'x') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x7f', 'query-reply') + expect(transport.sendInput).toHaveBeenNthCalledWith(2, 'x', 'query-reply') + expect(pane.terminal.modes.sendFocusMode).toBe(true) + + mockStoreState.agentStatusByPaneKey[paneKey] = { + ...doneStatus, + state: 'working' + } + notifyStoreSubscribers() + transport.sendInput.mockClear() + + sendTerminalInputThroughPane(pane, '\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') + expect(pane.terminal.modes.sendFocusMode).toBe(true) + + mockStoreState.agentStatusByPaneKey[paneKey] = { + ...doneStatus, + state: 'waiting' + } + notifyStoreSubscribers() + transport.sendInput.mockClear() + sendTerminalInputThroughPane(pane, '\x1b[O') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O', 'query-reply') + } finally { + restoreUserAgent() + } + }) + + it('keeps focus reports enabled for native Windows Cursor completion', async () => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + pane.terminal.modes.sendFocusMode = true + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'ship it', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'cursor', + paneKey, + stateHistory: [] + } + notifyStoreSubscribers() + transport.sendInput.mockClear() + + sendTerminalInputThroughPane(pane, '\x1b[O') + sendTerminalInputThroughPane(pane, '\x1b[I') + + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O', 'query-reply') + expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I', 'query-reply') + expect(pane.terminal.modes.sendFocusMode).toBe(true) + } finally { + restoreUserAgent() + } + }) + + it('unsubscribes the native Windows done reset watcher on pane dispose', async () => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + const binding = connectPanePty(pane as never, manager as never, deps as never) + binding.dispose() + pane.terminal.write.mockClear() + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'ship it', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'codex', + paneKey, + stateHistory: [] + } + notifyStoreSubscribers() + + expect(pane.terminal.write).not.toHaveBeenCalled() + } finally { + restoreUserAgent() + } + }) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts deleted file mode 100644 index 828bcc73795..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts +++ /dev/null @@ -1,730 +0,0 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - RESET_KITTY_KEYBOARD_PROTOCOL, - RESET_TERMINAL_CURSOR_STYLE -} from '../../../../shared/terminal-mode-reset-profiles' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import { - temporarilySetNavigatorUserAgent, - sendTerminalInputThroughPane -} from './pty-connection-test-dom' -import { - LEAF_1, - createMockTransport, - createPane, - createManager -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import type { MockTransport } from './pty-connection-test-pane-fixtures' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' - -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() -})) - -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ - toast: { - info: toastInfo - } -})) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. -vi.mock('./pty-dispatcher', async (importOriginal) => { - const actual = await importOriginal>() - return { - ...actual, - getEagerPtyBufferHandle: vi.fn(() => undefined) - } -}) - -function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) -} - -function notifyStoreSubscribers(): void { - for (const listener of storeSubscribers.slice()) { - listener(mockStoreState) - } -} - -describe('connectPanePty', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - it('resets renderer cursor style when an agent becomes idle', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!idleHandler) { - throw new Error('Expected onAgentBecameIdle to be registered') - } - - idleHandler('* Codex done') - - expect(pane.terminal.write).toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - }) - - it('keeps kitty keyboard state when a native Windows agent becomes idle', async () => { - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - try { - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!idleHandler) { - throw new Error('Expected onAgentBecameIdle to be registered') - } - - idleHandler('* Codex done') - - // Why: a finished turn is not a dead app; its kitty flags stay until the host sees it exit. - expect(pane.terminal.write).toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, - expect.any(Function) - ) - } finally { - restoreUserAgent() - } - }) - - it.each([ - { - name: 'WSL', - configure: (): void => { - mockStoreState.tabsByWorktree = { - 'wt-1': [{ id: 'tab-1', ptyId: null, shellOverride: 'wsl.exe' }] - } - } - }, - { - name: 'remote runtime', - configure: (): void => { - mockStoreState.repos = [ - { - id: 'repo1', - connectionId: null, - displayName: 'orca', - executionHostId: 'runtime:owner-runtime' - } - ] - } - } - ])( - 'keeps $name focus reports and idle reset remote-safe on Windows clients', - async ({ configure }) => { - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - try { - configure() - const pane = createPane(1) - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!idleHandler) { - throw new Error('Expected onAgentBecameIdle to be registered') - } - idleHandler('* Codex done') - - expect(pane.terminal.write).toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - transport.sendInput.mockClear() - sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') - } finally { - restoreUserAgent() - } - } - ) - - it('resets cursor style when native Windows hook status reaches done', async () => { - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - try { - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'ship it', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'codex', - paneKey, - stateHistory: [] - } - notifyStoreSubscribers() - expect(pane.terminal.write).not.toHaveBeenCalled() - - const doneStatus = { - state: 'done', - prompt: 'ship it', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'codex', - paneKey, - stateHistory: [] - } - mockStoreState.agentStatusByPaneKey[paneKey] = doneStatus - notifyStoreSubscribers() - - expect(pane.terminal.write).toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - } finally { - restoreUserAgent() - } - }) - - it('resets cursor style when a batched done→working→done burst lands as one publication', async () => { - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - try { - const paneKey = makePaneKey('tab-1', LEAF_1) - const firstDoneAt = Date.now() - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'first turn', - updatedAt: firstDoneAt, - stateStartedAt: firstDoneAt, - agentType: 'codex', - paneKey, - stateHistory: [{ state: 'working', prompt: 'first turn', startedAt: firstDoneAt - 5_000 }] - } - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - expect(pane.terminal.write).not.toHaveBeenCalled() - - // The burst's intermediate `working` never publishes, so the entry stays `done` end-to-end; - // only the stateHistory row proves a second turn ran and re-armed the kitty protocol. - const secondDoneAt = firstDoneAt + 10_000 - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'second turn', - updatedAt: secondDoneAt, - stateStartedAt: secondDoneAt, - agentType: 'codex', - paneKey, - stateHistory: [ - { state: 'working', prompt: 'first turn', startedAt: firstDoneAt - 5_000 }, - { state: 'done', prompt: 'first turn', startedAt: firstDoneAt }, - { state: 'working', prompt: 'second turn', startedAt: secondDoneAt - 2_000 } - ] - } - notifyStoreSubscribers() - - expect(pane.terminal.write).toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - } finally { - restoreUserAgent() - } - }) - - it('resets cursor style when a batched burst ends on working after a completed turn', async () => { - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - try { - const paneKey = makePaneKey('tab-1', LEAF_1) - const startedAt = Date.now() - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'ship it', - updatedAt: startedAt, - stateStartedAt: startedAt, - agentType: 'codex', - paneKey, - stateHistory: [] - } - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - notifyStoreSubscribers() - expect(pane.terminal.write).not.toHaveBeenCalled() - - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'follow-up', - updatedAt: startedAt + 8_000, - stateStartedAt: startedAt + 8_000, - agentType: 'codex', - paneKey, - stateHistory: [ - { state: 'working', prompt: 'ship it', startedAt }, - { state: 'done', prompt: 'ship it', startedAt: startedAt + 4_000 } - ] - } - notifyStoreSubscribers() - - expect(pane.terminal.write).toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - } finally { - restoreUserAgent() - } - }) - - it('keeps native Windows same-turn done repaints from re-resetting cursor style', async () => { - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - try { - const paneKey = makePaneKey('tab-1', LEAF_1) - const doneAt = Date.now() - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'ship it', - updatedAt: doneAt, - stateStartedAt: doneAt, - agentType: 'codex', - paneKey, - stateHistory: [] - } - notifyStoreSubscribers() - const writesAfterDone = (pane.terminal.write as ReturnType).mock.calls.length - - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'ship it', - updatedAt: doneAt + 1_000, - stateStartedAt: doneAt, - agentType: 'codex', - paneKey, - stateHistory: [] - } - notifyStoreSubscribers() - - expect((pane.terminal.write as ReturnType).mock.calls.length).toBe( - writesAfterDone - ) - } finally { - restoreUserAgent() - } - }) - - it('drops native Windows Codex focus reports while a history resume is idle', async () => { - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - try { - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - pane.terminal.modes.sendFocusMode = true - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - startup: { - command: "codex 'resume' 'codex-session-1'", - launchAgent: 'codex', - telemetry: { - agent_kind: 'codex', - launch_source: 'sidebar', - request_kind: 'resume' - } - } - }) as never - ) - - expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBeUndefined() - transport.sendInput.mockClear() - - sendTerminalInputThroughPane(pane, '\x1b[O') - sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).not.toHaveBeenCalled() - - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'continue the resumed session', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'codex', - paneKey, - stateHistory: [] - } - notifyStoreSubscribers() - - sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') - } finally { - restoreUserAgent() - } - }) - - it.each([ - { - name: 'Claude history resume', - launchAgent: 'claude', - agentKind: 'claude', - requestKind: 'resume' - }, - { - name: 'fresh Codex launch', - launchAgent: 'codex', - agentKind: 'codex', - requestKind: 'new' - } - ])( - 'keeps focus reports enabled for a native Windows $name', - async ({ launchAgent, agentKind, requestKind }) => { - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - try { - const pane = createPane(1) - pane.terminal.modes.sendFocusMode = true - - connectPanePty( - pane as never, - createManager(1) as never, - createDeps({ - startup: { - command: `${launchAgent} session-command`, - launchAgent, - telemetry: { - agent_kind: agentKind, - launch_source: 'sidebar', - request_kind: requestKind - } - } - }) as never - ) - - transport.sendInput.mockClear() - sendTerminalInputThroughPane(pane, '\x1b[O') - sendTerminalInputThroughPane(pane, '\x1b[I') - - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O', 'query-reply') - expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I', 'query-reply') - } finally { - restoreUserAgent() - } - } - ) - - it('drops only focus reports while native Windows Codex is done and resumes them when working', async () => { - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - try { - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - pane.terminal.modes.sendFocusMode = true - const doneStatus = { - state: 'done', - prompt: 'ship it', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'codex', - paneKey, - stateHistory: [] - } - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - - mockStoreState.agentStatusByPaneKey[paneKey] = doneStatus - notifyStoreSubscribers() - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - idleHandler?.('* Codex done') - transport.sendInput.mockClear() - - sendTerminalInputThroughPane(pane, '\x1b[O') - sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).not.toHaveBeenCalled() - - sendTerminalInputThroughPane(pane, '\x7f') - sendTerminalInputThroughPane(pane, 'x') - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x7f', 'query-reply') - expect(transport.sendInput).toHaveBeenNthCalledWith(2, 'x', 'query-reply') - expect(pane.terminal.modes.sendFocusMode).toBe(true) - - mockStoreState.agentStatusByPaneKey[paneKey] = { - ...doneStatus, - state: 'working' - } - notifyStoreSubscribers() - transport.sendInput.mockClear() - - sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') - expect(pane.terminal.modes.sendFocusMode).toBe(true) - - mockStoreState.agentStatusByPaneKey[paneKey] = { - ...doneStatus, - state: 'waiting' - } - notifyStoreSubscribers() - transport.sendInput.mockClear() - sendTerminalInputThroughPane(pane, '\x1b[O') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O', 'query-reply') - } finally { - restoreUserAgent() - } - }) - - it('keeps focus reports enabled for native Windows Cursor completion', async () => { - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - try { - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - pane.terminal.modes.sendFocusMode = true - - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'ship it', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'cursor', - paneKey, - stateHistory: [] - } - notifyStoreSubscribers() - transport.sendInput.mockClear() - - sendTerminalInputThroughPane(pane, '\x1b[O') - sendTerminalInputThroughPane(pane, '\x1b[I') - - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O', 'query-reply') - expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I', 'query-reply') - expect(pane.terminal.modes.sendFocusMode).toBe(true) - } finally { - restoreUserAgent() - } - }) - - it('unsubscribes the native Windows done reset watcher on pane dispose', async () => { - const restoreUserAgent = temporarilySetNavigatorUserAgent( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - ) - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - try { - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - const binding = connectPanePty(pane as never, manager as never, deps as never) - binding.dispose() - pane.terminal.write.mockClear() - - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'done', - prompt: 'ship it', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'codex', - paneKey, - stateHistory: [] - } - notifyStoreSubscribers() - - expect(pane.terminal.write).not.toHaveBeenCalled() - } finally { - restoreUserAgent() - } - }) -})