From d3e3b173e64d88835c866aa888fb501c1fa63e5a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 9 Oct 2026 18:39:13 -0700 Subject: [PATCH] fix(agents): one host-routed agent-detection entry point (#27054) * fix(agents): one host-routed agent-detection entry point preflight.detectAgents / refreshAgents take an optional { worktreeId } and the host resolves that workspace's project runtime (WSL included) or SSH target. Gated by preflight.workspace-scoped.v1; old peers keep the host default. Co-Authored-By: Claude * fix(agents): route detection by the worktree's own host record A repo id shared by local and SSH rows made the first row pick the detection host. Resolve the worktree from the host's list and apply the shared execution-host routing rule; ambiguous rows refuse. Dashboard falls back to the host-default list until a workspace's own list loads. Co-Authored-By: Claude * fix(agents): refuse an ambiguous workspace instead of probing the host Only selector_not_found keeps the host default; selector_ambiguous now fails closed. Adds runtime-level coverage of the lookup. Co-Authored-By: Claude * fix(agents): refuse an ambiguously owned folder workspace Co-Authored-By: Claude * test(agents): quick-launch runtime target carries the workspace Co-Authored-By: Claude --------- Co-authored-by: m4air Co-authored-by: Claude --- ...ession.pr-triage-agent.launchreplay-1.json | 118 ++-- ...on.pr-triage-preflight.detectagents-1.json | 610 +++++++++--------- .../matrix-session.pr-triage-repo.list-1.json | 78 +-- ...trix-session.pr-triage-settings.get-1.json | 118 ++-- ...local-agents-preflight.detectagents-1.json | 594 ++++++++--------- ...ings.new-tab-local-agents-repo.list-1.json | 78 +-- ...s.new-tab-local-agents-settings.get-1.json | 118 ++-- .../goldens/new-tab-local-agents.json | 78 +-- .../goldens/pr-triage-launch-unconfirmed.json | 74 +-- .../goldens/pr-triage-launch.json | 78 +-- .../pr-triage-prompt-not-delivered.json | 28 +- .../goldens/review-create-agent-refused.json | 78 +-- mobile/rpc-foundation/pilot-scenarios.json | 10 +- .../session/mobile-new-tab-agent-loader.ts | 4 +- src/main/ipc/preflight.ts | 17 +- src/main/local-project-runtime-resolution.ts | 26 +- .../workspace-agent-detection.test.ts | 299 +++++++++ .../preflight/workspace-agent-detection.ts | 142 ++++ .../agent-detection-host-resolution.test.ts | 97 +++ ...me-get-orchestration-dispatch-authority.ts | 43 ++ .../runtime/rpc/methods/preflight.test.ts | 55 +- src/main/runtime/rpc/methods/preflight.ts | 22 +- .../runtime/runtime-worktree-agent-startup.ts | 11 +- .../dashboard-worktree-launch-options.ts | 19 +- .../tab-bar/QuickLaunchButton.test.ts | 6 +- .../src/hooks/useAgentDetectionTarget.test.ts | 11 +- .../src/hooks/useAgentDetectionTarget.ts | 19 +- src/renderer/src/hooks/useDetectedAgents.ts | 37 +- .../lib/agent-detection-target-inventory.ts | 48 ++ .../lib/empty-workspace-default-agent-chat.ts | 34 +- .../lib/launch-agent-session-continuation.ts | 2 +- .../src/lib/local-preflight-context.ts | 18 +- .../slices/runtime-agent-inventory-key.ts | 15 + .../store/slices/runtime-detected-agents.ts | 396 ++++++------ src/shared/project-execution-runtime.ts | 14 + src/shared/protocol-version.ts | 5 + src/shared/rpc-contract/preflight-params.ts | 5 + .../rpc-params-catalog.generated.ts | 5 +- ...ht-workspace-scoped-downgrade.unit.test.ts | 187 ++++++ 39 files changed, 2283 insertions(+), 1314 deletions(-) create mode 100644 src/main/preflight/workspace-agent-detection.test.ts create mode 100644 src/main/preflight/workspace-agent-detection.ts create mode 100644 src/main/runtime/agent-detection-host-resolution.test.ts create mode 100644 src/renderer/src/lib/agent-detection-target-inventory.ts create mode 100644 src/renderer/src/store/slices/runtime-agent-inventory-key.ts create mode 100644 tests/e2e/cross-version-wire/preflight-workspace-scoped-downgrade.unit.test.ts diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-agent.launchreplay-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-agent.launchreplay-1.json index 1a8889872c5..81c4af34346 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-agent.launchreplay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-agent.launchreplay-1.json @@ -327,6 +327,11 @@ "message": "Couldn't start the agent." } }, + "742db574bda3": { + "name": "preflight.detectAgents#1", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"worktreeId\":\"repo-9::/w\"}}" + }, "7cdbda15d605": { "name": "agent.launchReplay#1", "ordinal": 7, @@ -419,38 +424,6 @@ "status": "pending", "startedAt": 0 }, - "9c16efc31903": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["codex"] - } - } - }, "a4273b38df83": { "launched": "unlaunched" }, @@ -742,6 +715,38 @@ "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, + "e5392ac00e18": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": ["codex"] + } + } + }, "eaa398d8c3aa": { "launched": { "kind": "unconfirmed", @@ -760,11 +765,6 @@ "launched": { "kind": "sent" } - }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" } }, "recording": { @@ -785,8 +785,8 @@ { "id": "pr-triage-launch.normal:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "ba3fac1eb199"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -797,8 +797,8 @@ { "id": "pr-triage-launch.result-absent:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "8638ab90602a"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "8638ab90602a"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -809,8 +809,8 @@ { "id": "pr-triage-launch.result-null:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "c39600966772"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "c39600966772"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -821,8 +821,8 @@ { "id": "pr-triage-launch.inner-ok-missing:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "ab12d545c466"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "ab12d545c466"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -833,8 +833,8 @@ { "id": "pr-triage-launch.inner-false-string-error:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "5b817dac4c7d"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "5b817dac4c7d"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -845,8 +845,8 @@ { "id": "pr-triage-launch.inner-false-object-error:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "7cdbda15d605"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "7cdbda15d605"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -857,8 +857,8 @@ { "id": "pr-triage-launch.outer-refused:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "a8f475e8a3a8"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "a8f475e8a3a8"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -869,8 +869,8 @@ { "id": "pr-triage-launch.outer-refused-no-message:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "4a304acaf84f"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "4a304acaf84f"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -881,8 +881,8 @@ { "id": "pr-triage-launch.method-not-found:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "b613a82cee3d"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "b613a82cee3d"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -896,14 +896,14 @@ "sender": [ "3e6a0417d2ee", "02188f091419", - "9c16efc31903", + "e5392ac00e18", "3b9fb3dfed63", "b81eb9edc2b2" ], "payloads": [ "e0eef7c9fafe", "1c2564f1daca", - "f49d184679d1", + "742db574bda3", "28339abecf78", "dca101029f70" ], @@ -920,14 +920,14 @@ "sender": [ "3e6a0417d2ee", "02188f091419", - "9c16efc31903", + "e5392ac00e18", "675c01bc8b85", "b81eb9edc2b2" ], "payloads": [ "e0eef7c9fafe", "1c2564f1daca", - "f49d184679d1", + "742db574bda3", "28339abecf78", "dca101029f70" ], diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-preflight.detectagents-1.json index 7f3f5f9c03a..20ef88baaae 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-preflight.detectagents-1.json @@ -41,7 +41,7 @@ } } }, - "0220267a7bca": { + "039b2277b75a": { "name": "preflight.detectAgents#1", "ordinal": 5, "args": [ @@ -52,42 +52,7 @@ { "name": "params", "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, - "id": "frame-3", - "ok": false - } - } - }, - "084fe2afb0ce": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" + "worktreeId": "repo-9::/w" } }, { @@ -110,6 +75,40 @@ } } }, + "1469e05f35f7": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, "1963ee2edae9": { "launched": { "kind": "not-started", @@ -228,7 +227,7 @@ "startedAt": 0 } }, - "570f5efcd445": { + "67471f63b6f7": { "name": "preflight.detectAgents#1", "ordinal": 5, "args": [ @@ -239,215 +238,7 @@ { "name": "params", "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-3", - "ok": false - } - } - }, - "57f451cceda3": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "585dfba86809": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true - } - } - }, - "6dd8724be3b1": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "9c16efc31903": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["codex"] - } - } - }, - "a4273b38df83": { - "launched": "unlaunched" - }, - "a9705081551e": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "error": { - "message": "inner refused" - }, - "ok": false - } - } - } - }, - "ac08a3ca5c67": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" + "worktreeId": "repo-9::/w" } }, { @@ -471,6 +262,148 @@ } } }, + "742db574bda3": { + "name": "preflight.detectAgents#1", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"worktreeId\":\"repo-9::/w\"}}" + }, + "786221fb36b0": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "880c4a7837af": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-3", + "ok": false + } + } + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "96aa7f51b001": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "a4273b38df83": { + "launched": "unlaunched" + }, + "a55db9667146": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true + } + } + }, "b13ddf18a62e": { "launched": { "kind": "not-started", @@ -535,12 +468,7 @@ } } }, - "e0eef7c9fafe": { - "name": "repo.list#1", - "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, - "e1b544c0b6ec": { + "bfd08cc3060a": { "name": "preflight.detectAgents#1", "ordinal": 5, "args": [ @@ -551,7 +479,7 @@ { "name": "params", "value": { - "$rpc": "absent" + "worktreeId": "repo-9::/w" } }, { @@ -562,36 +490,22 @@ } ], "settlement": { - "status": "rejected", + "status": "fulfilled", "startedAt": 0, "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } } } }, - "e35d7003f64c": { - "launched": { - "kind": "not-started", - "message": "transport failure" - } - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, - "eb8d47f90956": { - "launched": { - "kind": "sent" - } - }, - "efd2ac432554": { + "d85a8cd9155e": { "name": "preflight.detectAgents#1", "ordinal": 5, "args": [ @@ -602,7 +516,7 @@ { "name": "params", "value": { - "$rpc": "absent" + "worktreeId": "repo-9::/w" } }, { @@ -626,10 +540,96 @@ } } }, - "f49d184679d1": { + "e0eef7c9fafe": { + "name": "repo.list#1", + "ordinal": 3, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" + }, + "e35d7003f64c": { + "launched": { + "kind": "not-started", + "message": "transport failure" + } + }, + "e5392ac00e18": { "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": ["codex"] + } + } + }, + "e69d09287c64": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-3", + "ok": false + } + } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "eb8d47f90956": { + "launched": { + "kind": "sent" + } } }, "recording": { @@ -650,8 +650,8 @@ { "id": "pr-triage-launch.normal:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "ba3fac1eb199"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -662,8 +662,8 @@ { "id": "pr-triage-launch.result-absent:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "585dfba86809"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "02188f091419", "a55db9667146"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -674,8 +674,8 @@ { "id": "pr-triage-launch.result-null:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "6dd8724be3b1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "02188f091419", "1469e05f35f7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -686,8 +686,8 @@ { "id": "pr-triage-launch.inner-ok-missing:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "084fe2afb0ce"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "02188f091419", "039b2277b75a"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -698,8 +698,8 @@ { "id": "pr-triage-launch.inner-false-string-error:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "ac08a3ca5c67"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "02188f091419", "67471f63b6f7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -710,8 +710,8 @@ { "id": "pr-triage-launch.inner-false-object-error:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "a9705081551e"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "02188f091419", "bfd08cc3060a"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -722,8 +722,8 @@ { "id": "pr-triage-launch.outer-refused:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "570f5efcd445"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "02188f091419", "e69d09287c64"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -734,8 +734,8 @@ { "id": "pr-triage-launch.outer-refused-no-message:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "efd2ac432554"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "02188f091419", "d85a8cd9155e"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -746,8 +746,8 @@ { "id": "pr-triage-launch.method-not-found:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "0220267a7bca"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "02188f091419", "880c4a7837af"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -758,8 +758,8 @@ { "id": "pr-triage-launch.transport-rejection:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "e1b544c0b6ec"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "02188f091419", "96aa7f51b001"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -770,8 +770,8 @@ { "id": "pr-triage-launch.transport-rejection-no-message:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "57f451cceda3"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "02188f091419", "786221fb36b0"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-repo.list-1.json index 209476fc280..5e6ad0107b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-repo.list-1.json @@ -329,6 +329,11 @@ } } }, + "742db574bda3": { + "name": "preflight.detectAgents#1", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"worktreeId\":\"repo-9::/w\"}}" + }, "747c556da67a": { "name": "repo.list#1", "ordinal": 1, @@ -399,38 +404,6 @@ "status": "pending", "startedAt": 0 }, - "9c16efc31903": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["codex"] - } - } - }, "a4273b38df83": { "launched": "unlaunched" }, @@ -578,6 +551,38 @@ "message": "transport failure" } }, + "e5392ac00e18": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": ["codex"] + } + } + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -625,11 +630,6 @@ "ok": false } } - }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" } }, "recording": { @@ -650,8 +650,8 @@ { "id": "pr-triage-launch.normal:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "ba3fac1eb199"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-settings.get-1.json index 53f9961d97c..c70cc1bd063 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-settings.get-1.json @@ -358,6 +358,11 @@ } } }, + "742db574bda3": { + "name": "preflight.detectAgents#1", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"worktreeId\":\"repo-9::/w\"}}" + }, "753cc167886c": { "launched": { "kind": "not-started", @@ -440,38 +445,6 @@ "message": "Cannot read properties of undefined (reading 'settings')" } }, - "9c16efc31903": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["codex"] - } - } - }, "a4273b38df83": { "launched": "unlaunched" }, @@ -585,6 +558,38 @@ "message": "transport failure" } }, + "e5392ac00e18": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": ["codex"] + } + } + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -598,11 +603,6 @@ "kind": "sent" } }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" - }, "fb9f08890f70": { "name": "settings.get#1", "ordinal": 2, @@ -656,8 +656,8 @@ { "id": "pr-triage-launch.normal:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "ba3fac1eb199"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -668,8 +668,8 @@ { "id": "pr-triage-launch.result-absent:launched", "observation": { - "sender": ["3e6a0417d2ee", "98bd3a5fda2b", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "98bd3a5fda2b", "e5392ac00e18"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -680,8 +680,8 @@ { "id": "pr-triage-launch.result-null:launched", "observation": { - "sender": ["3e6a0417d2ee", "69518e60477b", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "69518e60477b", "e5392ac00e18"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -692,8 +692,8 @@ { "id": "pr-triage-launch.inner-ok-missing:launched", "observation": { - "sender": ["3e6a0417d2ee", "fb9f08890f70", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "fb9f08890f70", "e5392ac00e18", "ba3fac1eb199"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -704,8 +704,8 @@ { "id": "pr-triage-launch.inner-false-string-error:launched", "observation": { - "sender": ["3e6a0417d2ee", "86798e4821d1", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "86798e4821d1", "e5392ac00e18", "ba3fac1eb199"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -716,8 +716,8 @@ { "id": "pr-triage-launch.inner-false-object-error:launched", "observation": { - "sender": ["3e6a0417d2ee", "1c5359ea3f7d", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "1c5359ea3f7d", "e5392ac00e18", "ba3fac1eb199"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, @@ -728,8 +728,8 @@ { "id": "pr-triage-launch.outer-refused:launched", "observation": { - "sender": ["3e6a0417d2ee", "ceb5fe72c101", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "ceb5fe72c101", "e5392ac00e18"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -740,8 +740,8 @@ { "id": "pr-triage-launch.outer-refused-no-message:launched", "observation": { - "sender": ["3e6a0417d2ee", "34f3a5b8bd78", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "34f3a5b8bd78", "e5392ac00e18"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -752,8 +752,8 @@ { "id": "pr-triage-launch.method-not-found:launched", "observation": { - "sender": ["3e6a0417d2ee", "0ba58e9fadf7", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "0ba58e9fadf7", "e5392ac00e18"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -764,8 +764,8 @@ { "id": "pr-triage-launch.transport-rejection:launched", "observation": { - "sender": ["3e6a0417d2ee", "566d8202b582", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "566d8202b582", "e5392ac00e18"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, @@ -776,8 +776,8 @@ { "id": "pr-triage-launch.transport-rejection-no-message:launched", "observation": { - "sender": ["3e6a0417d2ee", "0ca529648be4", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["3e6a0417d2ee", "0ca529648be4", "e5392ac00e18"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3"], "settlements": { "launch": "eb79a9b3682a" }, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json index 2bd0e22edf7..bd8ceb4835c 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json @@ -41,7 +41,7 @@ } } }, - "0220267a7bca": { + "1782d7da7125": { "name": "preflight.detectAgents#1", "ordinal": 5, "args": [ @@ -52,7 +52,7 @@ { "name": "params", "value": { - "$rpc": "absent" + "worktreeId": "repo-1::/folder" } }, { @@ -68,48 +68,14 @@ "settledAt": 0, "value": { "error": { - "code": "method_not_found", - "message": "Unknown method" + "code": "refused", + "message": "" }, "id": "frame-3", "ok": false } } }, - "084fe2afb0ce": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "error": "refused" - } - } - } - }, "1c2564f1daca": { "name": "settings.get#1", "ordinal": 4, @@ -152,6 +118,73 @@ } }, "44136fa355b3": {}, + "4b4eff46e2be": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-1::/folder" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-3", + "ok": false + } + } + }, + "4b7de31b7ca7": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-1::/folder" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": ["codex", "claude"] + } + } + }, "51d4cb56be85": { "name": "settings.get#1", "ordinal": 2, @@ -178,137 +211,10 @@ "startedAt": 0 } }, - "570f5efcd445": { + "584e8a48c9d1": { "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-3", - "ok": false - } - } - }, - "57f451cceda3": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "585dfba86809": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true - } - } - }, - "6dd8724be3b1": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "$rpc": "null" - } - } - } + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"worktreeId\":\"repo-1::/folder\"}}" }, "70e0675ab2a9": { "name": "repo.list#1", @@ -351,7 +257,7 @@ } } }, - "89b73b1e13d1": { + "8f2549f081ef": { "name": "preflight.detectAgents#1", "ordinal": 5, "args": [ @@ -362,7 +268,7 @@ { "name": "params", "value": { - "$rpc": "absent" + "worktreeId": "repo-1::/folder" } }, { @@ -373,13 +279,13 @@ } ], "settlement": { - "status": "fulfilled", + "status": "rejected", "startedAt": 0, "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["codex", "claude"] + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true } } }, @@ -408,7 +314,7 @@ "isRpcDeliveryUnknown": true } }, - "a9705081551e": { + "ad56d2b94b5e": { "name": "preflight.detectAgents#1", "ordinal": 5, "args": [ @@ -419,7 +325,7 @@ { "name": "params", "value": { - "$rpc": "absent" + "worktreeId": "repo-1::/folder" } }, { @@ -430,53 +336,13 @@ } ], "settlement": { - "status": "fulfilled", + "status": "rejected", "startedAt": 0, "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "error": { - "message": "inner refused" - }, - "ok": false - } - } - } - }, - "ac08a3ca5c67": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true } } }, @@ -505,22 +371,7 @@ "isRpcDeliveryUnknown": false } }, - "c7584e82c72f": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - }, - "e0eef7c9fafe": { - "name": "repo.list#1", - "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, - "e1b544c0b6ec": { + "c0260c98bf87": { "name": "preflight.detectAgents#1", "ordinal": 5, "args": [ @@ -531,39 +382,7 @@ { "name": "params", "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, - "efd2ac432554": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" + "worktreeId": "repo-1::/folder" } }, { @@ -580,13 +399,134 @@ "value": { "error": { "code": "refused", - "message": "" + "message": "outer refused" }, "id": "frame-3", "ok": false } } }, + "c581f563511c": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-1::/folder" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "d0f8e1b60093": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-1::/folder" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "e0eef7c9fafe": { + "name": "repo.list#1", + "ordinal": 3, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" + }, + "ec9c870f9c3a": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-1::/folder" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, "f3b516f62081": { "status": "rejected", "startedAt": 0, @@ -597,10 +537,70 @@ "isRpcDeliveryUnknown": false } }, - "f49d184679d1": { + "fea75e1dbba4": { "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-1::/folder" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "ff00bccaaf1f": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-1::/folder" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true + } + } } }, "recording": { @@ -621,8 +621,8 @@ { "id": "new-tab-local-agents.normal:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "b27c85677730" }, @@ -633,8 +633,8 @@ { "id": "new-tab-local-agents.result-absent:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "585dfba86809"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "ff00bccaaf1f"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "98b298cb3858" }, @@ -645,8 +645,8 @@ { "id": "new-tab-local-agents.result-null:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "6dd8724be3b1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "fea75e1dbba4"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "98b298cb3858" }, @@ -657,8 +657,8 @@ { "id": "new-tab-local-agents.inner-ok-missing:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "084fe2afb0ce"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "ec9c870f9c3a"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "98b298cb3858" }, @@ -669,8 +669,8 @@ { "id": "new-tab-local-agents.inner-false-string-error:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "ac08a3ca5c67"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "c581f563511c"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "98b298cb3858" }, @@ -681,8 +681,8 @@ { "id": "new-tab-local-agents.inner-false-object-error:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "a9705081551e"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "d0f8e1b60093"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "98b298cb3858" }, @@ -693,8 +693,8 @@ { "id": "new-tab-local-agents.outer-refused:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "570f5efcd445"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "c0260c98bf87"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "32a7c0ae7918" }, @@ -705,8 +705,8 @@ { "id": "new-tab-local-agents.outer-refused-no-message:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "efd2ac432554"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "1782d7da7125"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "f3b516f62081" }, @@ -717,8 +717,8 @@ { "id": "new-tab-local-agents.method-not-found:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "0220267a7bca"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "4b4eff46e2be"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "b948e8307e81" }, @@ -729,8 +729,8 @@ { "id": "new-tab-local-agents.transport-rejection:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "e1b544c0b6ec"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "ad56d2b94b5e"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "a947768bc0ed" }, @@ -741,8 +741,8 @@ { "id": "new-tab-local-agents.transport-rejection-no-message:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "57f451cceda3"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "8f2549f081ef"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "c7584e82c72f" }, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json index 6a827810397..f9511790aad 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json @@ -218,6 +218,38 @@ } } }, + "4b7de31b7ca7": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-1::/folder" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": ["codex", "claude"] + } + } + }, "51d4cb56be85": { "name": "settings.get#1", "ordinal": 2, @@ -244,6 +276,11 @@ "startedAt": 0 } }, + "584e8a48c9d1": { + "name": "preflight.detectAgents#1", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"worktreeId\":\"repo-1::/folder\"}}" + }, "624ec4e5082c": { "name": "repo.list#1", "ordinal": 1, @@ -351,38 +388,6 @@ } } }, - "89b73b1e13d1": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["codex", "claude"] - } - } - }, "8a72d3d14d44": { "name": "repo.list#1", "ordinal": 1, @@ -596,11 +601,6 @@ "message": "", "isRpcDeliveryUnknown": false } - }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" } }, "recording": { @@ -621,8 +621,8 @@ { "id": "new-tab-local-agents.normal:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "b27c85677730" }, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json index 1c1b1fc507c..9fba3bbb4dc 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json @@ -257,6 +257,38 @@ ] }, "44136fa355b3": {}, + "4b7de31b7ca7": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-1::/folder" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": ["codex", "claude"] + } + } + }, "51d4cb56be85": { "name": "settings.get#1", "ordinal": 2, @@ -315,6 +347,11 @@ } } }, + "584e8a48c9d1": { + "name": "preflight.detectAgents#1", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"worktreeId\":\"repo-1::/folder\"}}" + }, "69518e60477b": { "name": "settings.get#1", "ordinal": 2, @@ -425,38 +462,6 @@ } } }, - "89b73b1e13d1": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["codex", "claude"] - } - } - }, "9270aeb7d9c6": { "status": "pending", "startedAt": 0 @@ -587,11 +592,6 @@ "isRpcDeliveryUnknown": false } }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" - }, "fb9f08890f70": { "name": "settings.get#1", "ordinal": 2, @@ -645,8 +645,8 @@ { "id": "new-tab-local-agents.normal:settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "b27c85677730" }, @@ -657,8 +657,8 @@ { "id": "new-tab-local-agents.result-absent:settled", "observation": { - "sender": ["70e0675ab2a9", "98bd3a5fda2b", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "98bd3a5fda2b", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "1c10eabc36a4" }, @@ -669,8 +669,8 @@ { "id": "new-tab-local-agents.result-null:settled", "observation": { - "sender": ["70e0675ab2a9", "69518e60477b", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "69518e60477b", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "1a04332d2ee1" }, @@ -681,8 +681,8 @@ { "id": "new-tab-local-agents.inner-ok-missing:settled", "observation": { - "sender": ["70e0675ab2a9", "fb9f08890f70", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "fb9f08890f70", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "3c911d72c9be" }, @@ -693,8 +693,8 @@ { "id": "new-tab-local-agents.inner-false-string-error:settled", "observation": { - "sender": ["70e0675ab2a9", "86798e4821d1", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "86798e4821d1", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "3c911d72c9be" }, @@ -705,8 +705,8 @@ { "id": "new-tab-local-agents.inner-false-object-error:settled", "observation": { - "sender": ["70e0675ab2a9", "1c5359ea3f7d", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "1c5359ea3f7d", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "3c911d72c9be" }, @@ -717,8 +717,8 @@ { "id": "new-tab-local-agents.outer-refused:settled", "observation": { - "sender": ["70e0675ab2a9", "ceb5fe72c101", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "ceb5fe72c101", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "32a7c0ae7918" }, @@ -729,8 +729,8 @@ { "id": "new-tab-local-agents.outer-refused-no-message:settled", "observation": { - "sender": ["70e0675ab2a9", "34f3a5b8bd78", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "34f3a5b8bd78", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "f3b516f62081" }, @@ -741,8 +741,8 @@ { "id": "new-tab-local-agents.method-not-found:settled", "observation": { - "sender": ["70e0675ab2a9", "0ba58e9fadf7", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "0ba58e9fadf7", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "b948e8307e81" }, @@ -753,8 +753,8 @@ { "id": "new-tab-local-agents.transport-rejection:settled", "observation": { - "sender": ["70e0675ab2a9", "566d8202b582", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "566d8202b582", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "a947768bc0ed" }, @@ -765,8 +765,8 @@ { "id": "new-tab-local-agents.transport-rejection-no-message:settled", "observation": { - "sender": ["70e0675ab2a9", "0ca529648be4", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "0ca529648be4", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "c7584e82c72f" }, diff --git a/mobile/rpc-foundation/goldens/new-tab-local-agents.json b/mobile/rpc-foundation/goldens/new-tab-local-agents.json index ee5701b5b68..bb8f718b396 100644 --- a/mobile/rpc-foundation/goldens/new-tab-local-agents.json +++ b/mobile/rpc-foundation/goldens/new-tab-local-agents.json @@ -73,6 +73,38 @@ } }, "44136fa355b3": {}, + "4b7de31b7ca7": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-1::/folder" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": ["codex", "claude"] + } + } + }, "51d4cb56be85": { "name": "settings.get#1", "ordinal": 2, @@ -99,6 +131,11 @@ "startedAt": 0 } }, + "584e8a48c9d1": { + "name": "preflight.detectAgents#1", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"worktreeId\":\"repo-1::/folder\"}}" + }, "70e0675ab2a9": { "name": "repo.list#1", "ordinal": 1, @@ -140,38 +177,6 @@ } } }, - "89b73b1e13d1": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["codex", "claude"] - } - } - }, "9270aeb7d9c6": { "status": "pending", "startedAt": 0 @@ -195,11 +200,6 @@ "name": "repo.list#1", "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" } }, "recording": { @@ -220,8 +220,8 @@ { "id": "settled", "observation": { - "sender": ["70e0675ab2a9", "02188f091419", "89b73b1e13d1"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["70e0675ab2a9", "02188f091419", "4b7de31b7ca7"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "584e8a48c9d1"], "settlements": { "load": "b27c85677730" }, diff --git a/mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json index cbfd4fbdbac..a2852000c2f 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json @@ -89,37 +89,10 @@ } } }, - "9c16efc31903": { + "742db574bda3": { "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["codex"] - } - } + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"worktreeId\":\"repo-9::/w\"}}" }, "c15f33d27bd2": { "name": "agent.launchReplay#1", @@ -171,6 +144,38 @@ "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, + "e5392ac00e18": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": ["codex"] + } + } + }, "eaa398d8c3aa": { "launched": { "kind": "unconfirmed", @@ -184,11 +189,6 @@ "value": { "$rpc": "undefined" } - }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" } }, "recording": { @@ -197,8 +197,8 @@ { "id": "unconfirmed", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "c15f33d27bd2"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "c15f33d27bd2"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, diff --git a/mobile/rpc-foundation/goldens/pr-triage-launch.json b/mobile/rpc-foundation/goldens/pr-triage-launch.json index 0b7a6ad752b..3e12457661d 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-launch.json +++ b/mobile/rpc-foundation/goldens/pr-triage-launch.json @@ -141,42 +141,15 @@ "startedAt": 0 } }, + "742db574bda3": { + "name": "preflight.detectAgents#1", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"worktreeId\":\"repo-9::/w\"}}" + }, "9270aeb7d9c6": { "status": "pending", "startedAt": 0 }, - "9c16efc31903": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["codex"] - } - } - }, "a4273b38df83": { "launched": "unlaunched" }, @@ -243,6 +216,38 @@ "ordinal": 3, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, + "e5392ac00e18": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": ["codex"] + } + } + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -255,11 +260,6 @@ "launched": { "kind": "sent" } - }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" } }, "recording": { @@ -280,8 +280,8 @@ { "id": "launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "ba3fac1eb199"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, diff --git a/mobile/rpc-foundation/goldens/pr-triage-prompt-not-delivered.json b/mobile/rpc-foundation/goldens/pr-triage-prompt-not-delivered.json index e7b46ef15e4..32eac1a8c50 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-prompt-not-delivered.json +++ b/mobile/rpc-foundation/goldens/pr-triage-prompt-not-delivered.json @@ -153,7 +153,17 @@ "prompt": "Fix the failing checks" } }, - "9c16efc31903": { + "742db574bda3": { + "name": "preflight.detectAgents#1", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"worktreeId\":\"repo-9::/w\"}}" + }, + "e0eef7c9fafe": { + "name": "repo.list#1", + "ordinal": 3, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" + }, + "e5392ac00e18": { "name": "preflight.detectAgents#1", "ordinal": 5, "args": [ @@ -164,7 +174,7 @@ { "name": "params", "value": { - "$rpc": "absent" + "worktreeId": "repo-9::/w" } }, { @@ -185,11 +195,6 @@ } } }, - "e0eef7c9fafe": { - "name": "repo.list#1", - "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -197,11 +202,6 @@ "value": { "$rpc": "undefined" } - }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" } }, "recording": { @@ -210,8 +210,8 @@ { "id": "prompt-not-sent", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "29436402d701"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["3e6a0417d2ee", "02188f091419", "e5392ac00e18", "29436402d701"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "742db574bda3", "28339abecf78"], "settlements": { "launch": "eb79a9b3682a" }, diff --git a/mobile/rpc-foundation/goldens/review-create-agent-refused.json b/mobile/rpc-foundation/goldens/review-create-agent-refused.json index 4b3e557ab71..3f969b517bb 100644 --- a/mobile/rpc-foundation/goldens/review-create-agent-refused.json +++ b/mobile/rpc-foundation/goldens/review-create-agent-refused.json @@ -9,6 +9,11 @@ "ordinal": 4, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, + "2398eeb5ec74": { + "name": "preflight.detectAgents#1", + "ordinal": 6, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\",\"params\":{\"worktreeId\":\"workspace-1\"}}" + }, "5ee9e7b49320": { "name": "agent.launchReplay#1", "ordinal": 8, @@ -91,6 +96,38 @@ } } }, + "bca41ddb4705": { + "name": "preflight.detectAgents#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "worktreeId": "workspace-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": ["claude"] + } + } + }, "bcb5111e6012": { "name": "agent.launchReplay#1", "ordinal": 7, @@ -149,11 +186,6 @@ "$rpc": "undefined" } }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" - }, "fc1691a7deca": { "actionError": "Workspace not found", "busyAction": { @@ -186,38 +218,6 @@ "sendSheet": { "$rpc": "null" } - }, - "fc3974e714de": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["claude"] - } - } } }, "recording": { @@ -226,8 +226,8 @@ { "id": "refused", "observation": { - "sender": ["9239a4f232b8", "96a9a954e28f", "fc3974e714de", "bcb5111e6012"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "5ee9e7b49320"], + "sender": ["9239a4f232b8", "96a9a954e28f", "bca41ddb4705", "bcb5111e6012"], + "payloads": ["e0eef7c9fafe", "1c2564f1daca", "2398eeb5ec74", "5ee9e7b49320"], "settlements": { "create-and-send": "eb79a9b3682a" }, diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index c5e49569934..1e4b1a20d62 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -9268,7 +9268,7 @@ { "complete": "preflight.detectAgents#1", "params": { - "$undefined": true + "worktreeId": "repo-9::/w" }, "reply": { "ok": true, @@ -9361,7 +9361,7 @@ { "complete": "preflight.detectAgents#1", "params": { - "$undefined": true + "worktreeId": "repo-9::/w" }, "reply": { "ok": true, @@ -9454,7 +9454,7 @@ { "complete": "preflight.detectAgents#1", "params": { - "$undefined": true + "worktreeId": "repo-9::/w" }, "reply": { "ok": true, @@ -18538,7 +18538,7 @@ { "complete": "preflight.detectAgents#1", "params": { - "$undefined": true + "worktreeId": "workspace-1" }, "reply": { "ok": true, @@ -23149,7 +23149,7 @@ { "complete": "preflight.detectAgents#1", "params": { - "$undefined": true + "worktreeId": "repo-1::/folder" }, "reply": { "ok": true, diff --git a/mobile/src/session/mobile-new-tab-agent-loader.ts b/mobile/src/session/mobile-new-tab-agent-loader.ts index 5cc02b54421..596ed118f5f 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.ts @@ -89,7 +89,9 @@ async function loadDetectedAgents( repo } : { - reply: await preflightDetectAgentsRead.request(client), + // Why the workspace: the host resolves its project runtime (a WSL distro on Windows). An + // older host discards the params and answers with its own default, as it always has. + reply: await preflightDetectAgentsRead.request(client, { worktreeId }), interpret: preflightDetectAgentsRead.interpret, repo } diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 581a7a86c66..1fbb2836055 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -1,11 +1,9 @@ import { ipcMain } from 'electron' import { - detectInstalledAgentsWithShellPathHydration, - detectRemoteAgents, detectRemoteWindowsTerminalCapabilities, - refreshShellPathAndDetectAgents, runPreflightCheck } from '../preflight/agent-detection' +import { detectAgentsOnHost, refreshAgentsOnHost } from '../preflight/workspace-agent-detection' import type { PreflightRuntimeContext, PreflightStatus, @@ -30,7 +28,7 @@ export function registerPreflightHandlers(): void { ) ipcMain.handle('preflight:detectAgents', async (_event, args?: PreflightRuntimeContext) => - detectInstalledAgentsWithShellPathHydration(args) + detectAgentsOnHost({ kind: 'local', context: args }) ) // Why here: this is the one place that already answers "what can the installed agent CLIs @@ -40,18 +38,13 @@ export function registerPreflightHandlers(): void { ) ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => { - return refreshShellPathAndDetectAgents(args) + return refreshAgentsOnHost({ kind: 'local', context: args }) }) - // Why: remote worktrees need agent detection on the SSH host, not the local - // machine. This handler forwards the same KNOWN_AGENT_COMMANDS list to the - // relay's preflight.detectAgents RPC, whose lookup command is selected on - // the remote host so native Windows OpenSSH does not require a POSIX shell. ipcMain.handle( 'preflight:detectRemoteAgents', - async (_event, args: { connectionId: string }): Promise => { - return detectRemoteAgents(args) - } + async (_event, args: { connectionId: string }): Promise => + detectAgentsOnHost({ kind: 'ssh', connectionId: args.connectionId }) ) ipcMain.handle( diff --git a/src/main/local-project-runtime-resolution.ts b/src/main/local-project-runtime-resolution.ts index b6886dcc339..2c9d3779b38 100644 --- a/src/main/local-project-runtime-resolution.ts +++ b/src/main/local-project-runtime-resolution.ts @@ -3,6 +3,7 @@ import type { GlobalSettings } from '../shared/global-settings-types' import type { Project } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import { + getWorkspaceRuntimePreference, resolveProjectExecutionRuntime, type ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' @@ -49,7 +50,7 @@ function canResolveProjectRuntimeForWorktreeId( function resolveLocalProjectRuntime( store: ResolvableStore, - project: Project, + project: Pick, settings: ReturnType = store.getSettings() ): ProjectExecutionRuntimeResolution { const wslAvailable = hasCachedWslAvailability() @@ -83,6 +84,29 @@ export function resolveLocalProjectRuntimeForRepo( return resolveLocalProjectRuntime(store, project) } +/** Same choice the renderer makes for a workspace: the project's runtime, else the distro of a + * WSL-share path, else the global default. Works for a repo with no project too. */ +export function resolveLocalWorkspaceRuntime( + store: ProjectRuntimeResolutionStore, + repo: Repo, + workspacePath: string | null | undefined +): ProjectExecutionRuntimeResolution | undefined { + if ( + getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID || + !canResolveProjectRuntimeForRepo(store) + ) { + return undefined + } + const project = store.getProjects().find((entry) => entry.sourceRepoIds.includes(repo.id)) + return resolveLocalProjectRuntime(store, { + id: project?.id ?? repo.id, + localWindowsRuntimePreference: getWorkspaceRuntimePreference( + project?.localWindowsRuntimePreference, + workspacePath + ) + }) +} + export function resolveLocalProjectRuntimesForRepos( store: ProjectRuntimeResolutionStore, repos: readonly Repo[] diff --git a/src/main/preflight/workspace-agent-detection.test.ts b/src/main/preflight/workspace-agent-detection.test.ts new file mode 100644 index 00000000000..ce10e12d9f2 --- /dev/null +++ b/src/main/preflight/workspace-agent-detection.test.ts @@ -0,0 +1,299 @@ +import '../runtime/rpc/unused-default-rpc-methods.test-fixture' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { PREFLIGHT_METHODS } from '../runtime/rpc/methods/preflight' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { GlobalWindowsRuntimeDefault } from '../../shared/project-execution-runtime' +import type { Project } from '../../shared/project-types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { Repo } from '../../shared/repo-types' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope' +import { splitWorktreeId } from '../../shared/worktree/id' +import { + resolveWorkspaceAgentDetectionHost, + type WorkspaceAgentDetectionStore +} from './workspace-agent-detection' + +const { detectInstalledMock, detectRemoteMock, refreshMock } = vi.hoisted(() => ({ + detectInstalledMock: vi.fn(), + detectRemoteMock: vi.fn(), + refreshMock: vi.fn() +})) + +vi.mock('./agent-detection', () => ({ + detectInstalledAgentsWithShellPathHydration: detectInstalledMock, + detectRemoteAgents: detectRemoteMock, + refreshShellPathAndDetectAgents: refreshMock, + detectRemoteWindowsTerminalCapabilities: vi.fn(), + runPreflightCheck: vi.fn() +})) + +const WSL_REPO_PATH = '\\\\wsl.localhost\\Ubuntu-24.04\\home\\me\\project\\repo' +const originalPlatform = process.platform + +function repo(overrides: Partial & Pick): Repo { + return { displayName: overrides.id, badgeColor: 'blue', addedAt: 1, ...overrides } +} + +function project( + id: string, + repoIds: string[], + preference?: Project['localWindowsRuntimePreference'] +): Project { + return { + id, + displayName: id, + badgeColor: 'blue', + sourceRepoIds: repoIds, + createdAt: 1, + updatedAt: 1, + ...(preference ? { localWindowsRuntimePreference: preference } : {}) + } +} + +const GROUP: ProjectGroup = { + id: 'g1', + name: 'g1', + parentPath: null, + connectionId: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const FOLDER: FolderWorkspace = { + id: 'f1', + projectGroupId: 'g1', + name: 'f1', + folderPath: WSL_REPO_PATH, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 +} + +function store(args: { + repos: Repo[] + projects?: Project[] + globalDefault?: GlobalWindowsRuntimeDefault + folderWorkspaces?: FolderWorkspace[] + projectGroups?: ProjectGroup[] +}): WorkspaceAgentDetectionStore { + return { + getRepos: () => args.repos, + getRepo: (id: string) => args.repos.find((entry) => entry.id === id), + getProjects: () => args.projects ?? [], + getSettings: () => ({ localWindowsRuntimeDefault: args.globalDefault }), + getFolderWorkspaces: () => args.folderWorkspaces ?? [], + getProjectGroups: () => args.projectGroups ?? [] + } +} + +function wslContext(distro: string, projectId: string, reason: string) { + return { + projectRuntime: { + status: 'resolved', + runtime: expect.objectContaining({ kind: 'wsl', distro, projectId, reason }) + } + } +} + +// Stands in for the runtime, which reads the worktree's host and path from its own list. +function resolveFor(s: WorkspaceAgentDetectionStore, workspaceId: string, hostId?: string) { + const scope = parseWorkspaceKey(workspaceId) + if (scope?.type === 'folder') { + return resolveWorkspaceAgentDetectionHost(s, { + kind: 'folder', + folderWorkspaceId: scope.folderWorkspaceId + }) + } + const parsed = splitWorktreeId(workspaceId) + if (!parsed) { + throw new Error(`not a worktree id: ${workspaceId}`) + } + return resolveWorkspaceAgentDetectionHost(s, { + kind: 'worktree', + repoId: parsed.repoId, + path: parsed.worktreePath, + hostId + }) +} + +describe('resolveWorkspaceAgentDetectionHost on a Windows host', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + }) + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + }) + + it('keeps the host default for a workspace no repo row owns', () => { + const s = store({ repos: [repo({ id: 'r1', path: 'C:\\r1' })] }) + expect(resolveFor(s, 'missing::C:\\x')).toEqual({ kind: 'local' }) + }) + + it('routes by the worktree owner, not the first row sharing its repo id', () => { + const sshRow = repo({ id: 'r1', path: '/srv/r1', executionHostId: 'ssh:build-host' }) + const localRow = repo({ id: 'r1', path: 'C:\\src\\r1', executionHostId: 'local' }) + for (const repos of [ + [sshRow, localRow], + [localRow, sshRow] + ]) { + const s = store({ repos }) + expect(resolveFor(s, 'r1::C:\\src\\r1', 'local')).toEqual({ + kind: 'local', + context: { + projectRuntime: { + status: 'resolved', + runtime: expect.objectContaining({ kind: 'windows-host' }) + } + } + }) + expect(resolveFor(s, 'r1::/srv/r1', 'ssh:build-host')).toEqual({ + kind: 'ssh', + connectionId: 'build-host' + }) + // Rival rows and no worktree host name no single owner: refuse rather than guess. + expect(() => resolveFor(s, 'r1::/srv/r1')).toThrow('worktree_execution_host_unresolved') + } + }) + + it('probes inside the distro of a WSL-share workspace with no saved runtime', () => { + const s = store({ repos: [repo({ id: 'r1', path: WSL_REPO_PATH })] }) + expect(resolveFor(s, `r1::${WSL_REPO_PATH}`)).toEqual({ + kind: 'local', + context: wslContext('Ubuntu-24.04', 'r1', 'project-override') + }) + }) + + it('follows the global WSL default for a project that inherits it', () => { + const s = store({ + repos: [repo({ id: 'r1', path: 'C:\\src\\r1' })], + projects: [project('p1', ['r1'])], + globalDefault: { kind: 'wsl', distro: 'Ubuntu' } + }) + expect(resolveFor(s, 'r1::C:\\src\\r1')).toEqual({ + kind: 'local', + context: wslContext('Ubuntu', 'p1', 'global-default') + }) + }) + + it("lets the project's own runtime win over a WSL-share path", () => { + const s = store({ + repos: [repo({ id: 'r1', path: WSL_REPO_PATH })], + projects: [project('p1', ['r1'], { kind: 'windows-host' })] + }) + expect(resolveFor(s, `r1::${WSL_REPO_PATH}`)).toEqual({ + kind: 'local', + context: { + projectRuntime: { + status: 'resolved', + runtime: expect.objectContaining({ kind: 'windows-host', reason: 'project-override' }) + } + } + }) + }) + + it('routes an SSH workspace to its target, under either ownership spelling', () => { + const s = store({ + repos: [ + repo({ id: 'legacy', path: '/srv/a', connectionId: 'ssh-a' }), + repo({ id: 'unified', path: '/srv/b', executionHostId: 'ssh:ssh-b' }) + ] + }) + expect(resolveFor(s, 'legacy::/srv/a')).toEqual({ + kind: 'ssh', + connectionId: 'ssh-a' + }) + expect(resolveFor(s, 'unified::/srv/b')).toEqual({ + kind: 'ssh', + connectionId: 'ssh-b' + }) + }) + + it('resolves a local folder workspace through its only repo', () => { + const s = store({ + repos: [repo({ id: 'r1', path: WSL_REPO_PATH, projectGroupId: 'g1' })], + projectGroups: [GROUP], + folderWorkspaces: [FOLDER] + }) + expect(resolveFor(s, folderWorkspaceKey('f1'))).toEqual({ + kind: 'local', + context: wslContext('Ubuntu-24.04', 'r1', 'project-override') + }) + }) + it('refuses a folder whose candidate repos live on different SSH hosts', () => { + const s = store({ + repos: [ + repo({ id: 'a', path: '/srv/a', executionHostId: 'ssh:one', projectGroupId: 'g1' }), + repo({ id: 'b', path: '/srv/b', executionHostId: 'ssh:two', projectGroupId: 'g1' }) + ], + projectGroups: [GROUP], + folderWorkspaces: [{ ...FOLDER, folderPath: '/srv' }] + }) + expect(() => resolveFor(s, folderWorkspaceKey('f1'))).toThrow( + 'worktree_execution_host_unresolved' + ) + }) +}) + +describe('preflight.detectAgents resolves the workspace on the host', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + detectInstalledMock.mockReset().mockResolvedValue(['claude', 'codex']) + }) + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + }) + + function dispatcherFor(s: WorkspaceAgentDetectionStore) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these methods read only resolveAgentDetectionHost and getRuntimeId from the runtime. + const runtime = { + getRuntimeId: () => 'host', + resolveAgentDetectionHost: async (worktreeId?: string) => + worktreeId ? resolveFor(s, worktreeId) : { kind: 'local' } + } as unknown as OrcaRuntimeService + return new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS }) + } + + it('#19885: a paired client naming a WSL-hosted workspace gets the distro probe', async () => { + const dispatcher = dispatcherFor(store({ repos: [repo({ id: 'r1', path: WSL_REPO_PATH })] })) + const response = await dispatcher.dispatch({ + id: '1', + authToken: 't', + method: 'preflight.detectAgents', + params: { worktreeId: `r1::${WSL_REPO_PATH}` } + }) + expect(detectInstalledMock).toHaveBeenCalledWith( + wslContext('Ubuntu-24.04', 'r1', 'project-override') + ) + expect(response).toMatchObject({ ok: true, result: ['claude', 'codex'] }) + }) + + it('#18666: a project on the global WSL default is probed in that distro', async () => { + const dispatcher = dispatcherFor( + store({ + repos: [repo({ id: 'r1', path: 'C:\\src\\r1' })], + projects: [project('p1', ['r1'])], + globalDefault: { kind: 'wsl', distro: 'Ubuntu' } + }) + ) + await dispatcher.dispatch({ + id: '1', + authToken: 't', + method: 'preflight.refreshAgents', + params: { worktreeId: 'r1::C:\\src\\r1' } + }) + expect(refreshMock).toHaveBeenCalledWith(wslContext('Ubuntu', 'p1', 'global-default')) + }) +}) diff --git a/src/main/preflight/workspace-agent-detection.ts b/src/main/preflight/workspace-agent-detection.ts new file mode 100644 index 00000000000..9b700225f8b --- /dev/null +++ b/src/main/preflight/workspace-agent-detection.ts @@ -0,0 +1,142 @@ +import { + getRepoExecutionHostId, + getSshTargetIdForExecutionHost, + LOCAL_EXECUTION_HOST_ID +} from '../../shared/execution-host' +import { + findFolderWorkspaceCandidateRepos, + resolveFolderWorkspaceHost +} from '../../shared/folder-workspace-execution-host' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { Repo } from '../../shared/repo-types' +import { + resolveLocalWorkspaceRuntime, + type ProjectRuntimeResolutionStore +} from '../local-project-runtime-resolution' +import type { PreflightRuntimeContext } from '../ipc/preflight-runtime-target' +import { resolveWorktreeHostRouting } from '../runtime/worktree-launch-host-repo' +import { + detectInstalledAgentsWithShellPathHydration, + detectRemoteAgents, + refreshShellPathAndDetectAgents, + type RefreshAgentsResult +} from './agent-detection' + +/** Where agent CLIs are probed: this machine (optionally inside a WSL distro) or an SSH target. */ +export type AgentDetectionHost = + | { kind: 'local'; context?: PreflightRuntimeContext } + | { kind: 'ssh'; connectionId: string } + +export type WorkspaceAgentDetectionStore = ProjectRuntimeResolutionStore & { + getRepos?: () => Repo[] + getFolderWorkspaces?: () => FolderWorkspace[] + getProjectGroups?: () => ProjectGroup[] +} + +export const HOST_DEFAULT_AGENT_DETECTION: AgentDetectionHost = { kind: 'local' } + +/** A workspace as this host knows it: a folder workspace, or a worktree read from its own list. */ +export type AgentDetectionWorkspace = + | { kind: 'folder'; folderWorkspaceId: string } + | { kind: 'worktree'; repoId: string; path: string; hostId?: string | null } + +type ResolvedWorkspace = + | { kind: 'ssh'; connectionId: string } + | { kind: 'local'; repo: Repo; path: string } + +/** + * The host a workspace's agents run on, resolved on this machine from its own project settings. + * A workspace this store cannot place keeps the host default older clients rely on. + */ +export function resolveWorkspaceAgentDetectionHost( + store: WorkspaceAgentDetectionStore, + workspace: AgentDetectionWorkspace +): AgentDetectionHost { + const resolved = + workspace.kind === 'folder' + ? resolveFolderWorkspace(store, workspace.folderWorkspaceId) + : resolveWorktree(store, workspace) + if (!resolved) { + return HOST_DEFAULT_AGENT_DETECTION + } + if (resolved.kind === 'ssh') { + return resolved + } + const projectRuntime = resolveLocalWorkspaceRuntime(store, resolved.repo, resolved.path) + return projectRuntime + ? { kind: 'local', context: { projectRuntime } } + : HOST_DEFAULT_AGENT_DETECTION +} + +function resolveWorktree( + store: WorkspaceAgentDetectionStore, + worktree: Extract +): ResolvedWorkspace | null { + // Why the shared rule: one repo id can name rows on several hosts, and the first row is not + // necessarily this worktree's owner. + const routing = resolveWorktreeHostRouting(store.getRepos?.() ?? [], worktree) + if (routing.kind === 'ambiguous') { + throw new Error('worktree_execution_host_unresolved') + } + if (routing.kind === 'unowned') { + return null + } + const sshTargetId = getSshTargetIdForExecutionHost(routing.hostId) + if (sshTargetId) { + return { kind: 'ssh', connectionId: sshTargetId } + } + // A worktree another runtime owns is not this host's to probe; keep the host default. + return routing.hostId === LOCAL_EXECUTION_HOST_ID && routing.repo + ? { kind: 'local', repo: routing.repo, path: worktree.path } + : null +} + +function resolveFolderWorkspace( + store: WorkspaceAgentDetectionStore, + folderWorkspaceId: string +): ResolvedWorkspace | null { + const state = { + folderWorkspaces: store.getFolderWorkspaces?.() ?? [], + projectGroups: store.getProjectGroups?.() ?? [], + repos: store.getRepos?.() ?? [] + } + const host = resolveFolderWorkspaceHost(state, folderWorkspaceId) + if (host.kind === 'ssh') { + return { kind: 'ssh', connectionId: host.targetId } + } + if (host.kind === 'ambiguous') { + throw new Error('worktree_execution_host_unresolved') + } + const folder = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + const candidates = findFolderWorkspaceCandidateRepos(state, folderWorkspaceId) + // Why one candidate: a folder spanning several repos has no single project runtime to pick. + const repo = candidates.length === 1 ? candidates[0] : undefined + return host.kind === 'local' && + folder && + repo && + getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID + ? { kind: 'local', repo, path: folder.folderPath } + : null +} + +/** The one agent-detection entry point: IPC, runtime RPC, and SSH all probe through here. */ +export function detectAgentsOnHost(host: AgentDetectionHost): Promise { + return host.kind === 'ssh' + ? detectRemoteAgents({ connectionId: host.connectionId }) + : detectInstalledAgentsWithShellPathHydration(host.context) +} + +export async function refreshAgentsOnHost(host: AgentDetectionHost): Promise { + if (host.kind === 'local') { + return refreshShellPathAndDetectAgents(host.context) + } + // Why: an SSH probe already reads the target's fresh login PATH; there is no local PATH to merge. + return { + agents: await detectRemoteAgents({ connectionId: host.connectionId }), + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'sync_seed_only', + pathFailureReason: 'none' + } +} diff --git a/src/main/runtime/agent-detection-host-resolution.test.ts b/src/main/runtime/agent-detection-host-resolution.test.ts new file mode 100644 index 00000000000..2148c7ef7e2 --- /dev/null +++ b/src/main/runtime/agent-detection-host-resolution.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const electronMocks = vi.hoisted(() => { + const ipcMain = { + on: vi.fn(() => ipcMain), + removeListener: vi.fn(() => ipcMain), + emit: vi.fn(() => true) + } + return { + BrowserWindow: { fromId: vi.fn((): unknown => null) }, + webContents: { fromId: vi.fn((): unknown => null) }, + ipcMain, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } + } +}) +vi.mock('electron', () => electronMocks) + +import { OrcaRuntimeService } from './orca-runtime' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import type { Repo } from '../../shared/repo-types' +import type { ResolvedWorktree } from './runtime-worktree-path-identity' + +const originalPlatform = process.platform + +function makeRuntime(repos: Repo[]) { + const store = { + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getRepos: () => repos, + getProjects: () => [], + getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: detection resolution reads only the repo, project and settings members supplied here. + const runtime = new OrcaRuntimeService(store as never) + const selector = vi.fn<(selector: string) => Promise>() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: replaces the protected worktree lookup this method awaits; its signature is restated above. + ;(runtime as unknown as { resolveWorktreeSelector: typeof selector }).resolveWorktreeSelector = + selector + return { runtime, selector } +} + +function worktree(fields: Partial): ResolvedWorktree { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the method reads only repoId, path and hostId. + return fields as ResolvedWorktree +} + +describe('OrcaRuntimeService.resolveAgentDetectionHost', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + }) + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + }) + + const local: Repo = { + id: 'r1', + path: 'C:\\src\\r1', + displayName: 'r1', + badgeColor: 'blue', + addedAt: 1, + executionHostId: 'local' + } + + it('keeps the host default for no, floating, or unknown workspaces', async () => { + const { runtime, selector } = makeRuntime([local]) + selector.mockRejectedValue(new Error('selector_not_found')) + expect(await runtime.resolveAgentDetectionHost(undefined)).toEqual({ kind: 'local' }) + expect(await runtime.resolveAgentDetectionHost(FLOATING_TERMINAL_WORKTREE_ID)).toEqual({ + kind: 'local' + }) + expect(await runtime.resolveAgentDetectionHost('r1::C:\\gone')).toEqual({ kind: 'local' }) + expect(selector).toHaveBeenCalledTimes(1) + }) + + it('refuses a workspace id two hosts share instead of probing this one', async () => { + const { runtime, selector } = makeRuntime([local]) + selector.mockRejectedValue(new Error('selector_ambiguous')) + await expect(runtime.resolveAgentDetectionHost('r1::/srv/r1')).rejects.toThrow( + 'selector_ambiguous' + ) + }) + + it("routes by the host's own worktree record", async () => { + const ssh: Repo = { ...local, path: '/srv/r1', executionHostId: 'ssh:build-host' } + const { runtime, selector } = makeRuntime([ssh, local]) + selector.mockResolvedValueOnce(worktree({ repoId: 'r1', path: 'C:\\src\\r1', hostId: 'local' })) + expect(await runtime.resolveAgentDetectionHost('r1::C:\\src\\r1')).toEqual({ + kind: 'local', + context: { + projectRuntime: { + status: 'resolved', + runtime: expect.objectContaining({ kind: 'wsl', distro: 'Ubuntu' }) + } + } + }) + expect(selector).toHaveBeenCalledWith('id:r1::C:\\src\\r1') + }) +}) diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 74301c16b01..4744df0db99 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -13,6 +13,14 @@ import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-out import { appendRecentPtyPathCandidates } from './terminal-output-path-candidates' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' +import { + HOST_DEFAULT_AGENT_DETECTION, + resolveWorkspaceAgentDetectionHost, + type AgentDetectionHost +} from '../preflight/workspace-agent-detection' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import type { ResolvedWorktree } from './runtime-worktree-path-identity' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { localOrchestrationCliCommand, @@ -248,6 +256,41 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim : undefined } + // Why the host's own worktree record: it carries the owning host and the real path, where the + // client's id alone could pick the wrong row for a repo id shared across hosts. + async resolveAgentDetectionHost( + worktreeId: string | null | undefined + ): Promise { + if (!this.store || !worktreeId || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return HOST_DEFAULT_AGENT_DETECTION + } + const store = this.requireStore() + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + return resolveWorkspaceAgentDetectionHost(store, { + kind: 'folder', + folderWorkspaceId: scope.folderWorkspaceId + }) + } + let worktree: ResolvedWorktree + try { + worktree = await this.resolveWorktreeSelector(`id:${worktreeId}`) + } catch (error) { + // Only an unknown workspace keeps the answer older clients always got; an ambiguous one + // has no single host, and probing this one would list another machine's agents. + if (error instanceof Error && error.message === 'selector_not_found') { + return HOST_DEFAULT_AGENT_DETECTION + } + throw error + } + return resolveWorkspaceAgentDetectionHost(store, { + kind: 'worktree', + repoId: worktree.repoId, + path: worktree.path, + hostId: worktree.hostId + }) + } + getOrchestrationFleetAgentStatusSnapshot(): readonly FleetAgentStatusEvidence[] { return readOrchestrationFleetAgentStatusSnapshot(this) } diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index 88fa0bf2057..09f216e4b9e 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -58,14 +58,22 @@ describe('preflight RPC methods', () => { pathSource: 'shell_hydrate', pathFailureReason: 'none' }) - const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const resolveAgentDetectionHost = vi.fn(() => ({ kind: 'local' }) as const) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these methods read only resolveAgentDetectionHost and getRuntimeId from the runtime. + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveAgentDetectionHost + } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS }) const detected = await dispatcher.dispatch(makeRequest('preflight.detectAgents')) - const refreshed = await dispatcher.dispatch(makeRequest('preflight.refreshAgents')) + const refreshed = await dispatcher.dispatch(makeRequest('preflight.refreshAgents', null)) - expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled() - expect(refreshShellPathAndDetectAgentsMock).toHaveBeenCalled() + // An older client sends no workspace and keeps the host-default probe. + expect(resolveAgentDetectionHost).toHaveBeenNthCalledWith(1, undefined) + expect(resolveAgentDetectionHost).toHaveBeenNthCalledWith(2, undefined) + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalledWith(undefined) + expect(refreshShellPathAndDetectAgentsMock).toHaveBeenCalledWith(undefined) expect(detected).toMatchObject({ ok: true, result: ['codex'] }) expect(refreshed).toMatchObject({ ok: true, @@ -73,6 +81,45 @@ describe('preflight RPC methods', () => { }) }) + it('probes the runtime the host resolves for the requested workspace (#19885)', async () => { + const projectRuntime = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'p1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'p1:wsl:Ubuntu' + } + } as const + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['codex']) + detectRemoteAgentsMock.mockResolvedValueOnce(['claude']) + const resolveAgentDetectionHost = vi.fn((worktreeId?: string) => + worktreeId === 'repo-ssh::/srv/w' + ? ({ kind: 'ssh', connectionId: 'ssh-1' } as const) + : ({ kind: 'local', context: { projectRuntime } } as const) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these methods read only resolveAgentDetectionHost and getRuntimeId from the runtime. + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveAgentDetectionHost + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS }) + + const wsl = await dispatcher.dispatch( + makeRequest('preflight.detectAgents', { worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\w' }) + ) + const ssh = await dispatcher.dispatch( + makeRequest('preflight.detectAgents', { worktreeId: 'repo-ssh::/srv/w' }) + ) + + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalledWith({ projectRuntime }) + expect(wsl).toMatchObject({ ok: true, result: ['codex'] }) + expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' }) + expect(ssh).toMatchObject({ ok: true, result: ['claude'] }) + }) + it('detects agents on remote SSH connections through runtime RPC', async () => { detectRemoteAgentsMock.mockResolvedValueOnce(['claude']) const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index c4ef0d6f10f..b6c241522c2 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -1,12 +1,14 @@ import { defineMethod } from '../core' import { - detectRemoteAgents, detectRemoteWindowsTerminalCapabilities, - detectInstalledAgentsWithShellPathHydration, - refreshShellPathAndDetectAgents, runPreflightCheck } from '../../../preflight/agent-detection' import { + detectAgentsOnHost, + refreshAgentsOnHost +} from '../../../preflight/workspace-agent-detection' +import { + PreflightAgentDetection, PreflightCheck, PreflightDetectRemoteAgents, PreflightDetectRemoteWindowsTerminalCapabilities @@ -22,14 +24,17 @@ export const PREFLIGHT_METHODS = [ defineMethod({ name: 'preflight.detectAgents', permission: 'workspace', - params: null, - handler: async () => detectInstalledAgentsWithShellPathHydration() + params: PreflightAgentDetection, + // Why the host resolves: only it knows the workspace's project runtime, including WSL. + handler: async (params, { runtime }) => + detectAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId)) }), defineMethod({ name: 'preflight.detectRemoteAgents', permission: 'workspace', params: PreflightDetectRemoteAgents, - handler: async (params) => detectRemoteAgents(params) + handler: async (params) => + detectAgentsOnHost({ kind: 'ssh', connectionId: params.connectionId }) }), defineMethod({ name: 'preflight.detectRemoteWindowsTerminalCapabilities', @@ -40,7 +45,8 @@ export const PREFLIGHT_METHODS = [ defineMethod({ name: 'preflight.refreshAgents', permission: 'workspace', - params: null, - handler: async () => refreshShellPathAndDetectAgents() + params: PreflightAgentDetection, + handler: async (params, { runtime }) => + refreshAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId)) }) ] diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index d5021488b40..72b083b82a7 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -14,10 +14,7 @@ import { launchHostProvesAgentInFront, nameLocalTypedLineShell } from './agent-launch-typed-line-shell' -import { - detectInstalledAgentsWithShellPathHydration, - detectRemoteAgents -} from '../preflight/agent-detection' +import { detectAgentsOnHost } from '../preflight/workspace-agent-detection' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' @@ -61,9 +58,9 @@ export async function resolveWorktreeStartupDraftAgent( const sshConnectionId = getRepoSshConnectionId(repo) try { // Why: startup-draft fallback can run from sparse runtime launch envs too. - detected = sshConnectionId - ? await detectRemoteAgents({ connectionId: sshConnectionId }) - : await detectInstalledAgentsWithShellPathHydration() + detected = await detectAgentsOnHost( + sshConnectionId ? { kind: 'ssh', connectionId: sshConnectionId } : { kind: 'local' } + ) } catch { detected = [] } diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts index 44c76a74fee..649b961a680 100644 --- a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts @@ -12,6 +12,7 @@ import { } from '../../../../shared/tui-agent-selection' import type { TuiAgent } from '../../../../shared/tui-agent' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key' export type DashboardLaunchDetectionState = Pick< AppState, @@ -46,6 +47,20 @@ function buildDashboardLaunchCatalog(state: DashboardLaunchOptionState): Dashboa } } +// Why the host-default fallback: a card can render before any launch surface probed its workspace. +function runtimeAgentsForWorkspace( + state: DashboardLaunchOptionState, + environmentId: string, + worktreeId: string +): readonly TuiAgent[] { + const agents = state.runtimeDetectedAgentIds + return ( + agents?.[getRuntimeAgentInventoryKey(environmentId, worktreeId)] ?? + agents?.[environmentId] ?? + [] + ) +} + function detectedAgentsForWorktree( state: DashboardLaunchOptionState, worktreeId: string, @@ -58,7 +73,7 @@ function detectedAgentsForWorktree( const group = folder ? catalog.groupsById.get(folder.projectGroupId) : undefined const host = parseExecutionHostId(group?.executionHostId) if (host?.kind === 'runtime') { - return state.runtimeDetectedAgentIds?.[host.environmentId] ?? [] + return runtimeAgentsForWorkspace(state, host.environmentId, worktreeId) } const connectionId = folder?.connectionId ?? group?.connectionId return connectionId ? (state.remoteDetectedAgentIds?.[connectionId] ?? []) : [] @@ -68,7 +83,7 @@ function detectedAgentsForWorktree( const repo = catalog.reposById.get(worktree?.repoId ?? repoId) const host = parseExecutionHostId(worktree?.hostId ?? repo?.executionHostId) if (host?.kind === 'runtime') { - return state.runtimeDetectedAgentIds?.[host.environmentId] ?? [] + return runtimeAgentsForWorkspace(state, host.environmentId, worktreeId) } const connectionId = host?.kind === 'ssh' ? host.targetId : repo?.connectionId return connectionId diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts index bb2f1cf02f7..5b25e7c54e5 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts @@ -159,7 +159,8 @@ describe('QuickLaunchAgentMenuItems', () => { expect(useDetectedAgentsMock).toHaveBeenLastCalledWith({ kind: 'runtime', - environmentId: 'env-1' + environmentId: 'env-1', + worktreeId: 'worktree-1' }) }) @@ -179,7 +180,8 @@ describe('QuickLaunchAgentMenuItems', () => { expect(useDetectedAgentsMock).toHaveBeenLastCalledWith({ kind: 'runtime', - environmentId: 'env-1' + environmentId: 'env-1', + worktreeId: 'worktree-1' }) }) diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts index 16cf58acf1f..833d2d6cc5e 100644 --- a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts +++ b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts @@ -70,7 +70,7 @@ describe('getAgentDetectionTargetKeyForWorktree', () => { } as Parameters[0] expect(getAgentDetectionTargetKeyForWorktree(state, folderWorkspaceKey('runtime-folder'))).toBe( - 'runtime:owner-env' + `runtime:owner-env:${encodeURIComponent(folderWorkspaceKey('runtime-folder'))}` ) expect(projectGroupReads).toBe(0) }) @@ -116,7 +116,14 @@ describe('getAgentDetectionTargetKeyForWorktree', () => { } } as unknown as Parameters[0] - expect(getAgentDetectionTargetKeyForWorktree(state, 'repo-1::worktree-1')).toBe('runtime:env-1') + const key = getAgentDetectionTargetKeyForWorktree(state, 'repo-1::worktree-1') + // The workspace rides along so the paired host resolves its runtime (#19885). + expect(key).toBe('runtime:env-1:repo-1%3A%3Aworktree-1') + expect(parseAgentDetectionTargetKey(key)).toEqual({ + kind: 'runtime', + environmentId: 'env-1', + worktreeId: 'repo-1::worktree-1' + }) }) it('builds one owner index per cold worktree and repo snapshot', () => { diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.ts b/src/renderer/src/hooks/useAgentDetectionTarget.ts index f8c5787a901..5323c8786f1 100644 --- a/src/renderer/src/hooks/useAgentDetectionTarget.ts +++ b/src/renderer/src/hooks/useAgentDetectionTarget.ts @@ -14,6 +14,11 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' export const AGENT_DETECTION_LOCAL_TARGET_KEY = 'local' +// Why the workspace rides along: the paired host resolves that workspace's runtime (e.g. WSL). +function getRuntimeAgentDetectionTargetKey(environmentId: string, worktreeId: string): string { + return `runtime:${encodeURIComponent(environmentId)}:${encodeURIComponent(worktreeId)}` +} + function getLocalAgentDetectionTargetKey(worktreeId: string): string { return worktreeId === FLOATING_TERMINAL_WORKTREE_ID ? `${AGENT_DETECTION_LOCAL_TARGET_KEY}:${encodeURIComponent(worktreeId)}:host` @@ -45,7 +50,7 @@ export function getAgentDetectionTargetKeyForWorktree( worktreeId ) if (explicitRuntimeEnvironmentId) { - return `runtime:${explicitRuntimeEnvironmentId}` + return getRuntimeAgentDetectionTargetKey(explicitRuntimeEnvironmentId, worktreeId) } // Why: a hostless folder can span local and SSH children, so keep the // ambiguity gate before applying its focused-runtime fallback. @@ -62,7 +67,7 @@ export function getAgentDetectionTargetKeyForWorktree( return `ssh:${executionHost.targetId}` } if (executionHost?.kind === 'runtime') { - return `runtime:${executionHost.environmentId}` + return getRuntimeAgentDetectionTargetKey(executionHost.environmentId, worktreeId) } return getLocalAgentDetectionTargetKey(worktreeId) } @@ -97,7 +102,15 @@ export function parseAgentDetectionTargetKey( return { kind: 'ssh', connectionId: key.slice('ssh:'.length) } } if (key.startsWith('runtime:')) { - return { kind: 'runtime', environmentId: key.slice('runtime:'.length) } + const [encodedEnvironmentId = '', encodedWorktreeId] = key.slice('runtime:'.length).split(':') + try { + const environmentId = decodeURIComponent(encodedEnvironmentId) + return encodedWorktreeId + ? { kind: 'runtime', environmentId, worktreeId: decodeURIComponent(encodedWorktreeId) } + : { kind: 'runtime', environmentId } + } catch { + return { kind: 'runtime', environmentId: encodedEnvironmentId } + } } return { kind: 'local' } } diff --git a/src/renderer/src/hooks/useDetectedAgents.ts b/src/renderer/src/hooks/useDetectedAgents.ts index 8ed723a5ad5..11f7bb6266c 100644 --- a/src/renderer/src/hooks/useDetectedAgents.ts +++ b/src/renderer/src/hooks/useDetectedAgents.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef } from 'react' import { useAppStore } from '@/store' import type { TuiAgent } from '../../../shared/tui-agent' +import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key' export type UseDetectedAgentsResult = { /** Null while detection is in flight on first load. */ @@ -19,7 +20,8 @@ export type UseDetectedAgentsResult = { export type AgentDetectionTarget = | { kind: 'local'; worktreeId?: string | null; contextKey?: string } | { kind: 'ssh'; connectionId: string } - | { kind: 'runtime'; environmentId: string } + // worktreeId: the host resolves that workspace's runtime; absent means the host default. + | { kind: 'runtime'; environmentId: string; worktreeId?: string | null } function normalizeAgentDetectionTarget( target: AgentDetectionTarget | string | null | undefined @@ -66,12 +68,18 @@ export function useDetectedAgents( ? target.environmentId : null const localWorktreeId = target?.kind === 'local' ? target.worktreeId : undefined + const runtimeWorktreeId = target?.kind === 'runtime' ? target.worktreeId : undefined + // Records for a runtime host are per workspace; ssh/local records keep their own keys. + const recordKey = + targetKind === 'runtime' && targetId + ? getRuntimeAgentInventoryKey(targetId, runtimeWorktreeId) + : targetId const localContextKey = target?.kind === 'local' ? target.contextKey : undefined const remoteTargetKey = targetKind === 'ssh' && targetId ? `ssh:${targetId}` - : targetKind === 'runtime' && targetId - ? `runtime:${targetId}` + : targetKind === 'runtime' && recordKey + ? `runtime:${recordKey}` : null const detectedIds = useAppStore((s) => { @@ -81,8 +89,8 @@ export function useDetectedAgents( if (targetKind === 'ssh' && targetId) { return s.remoteDetectedAgentIds[targetId] ?? null } - if (targetKind === 'runtime' && targetId) { - return s.runtimeDetectedAgentIds[targetId] ?? null + if (targetKind === 'runtime' && recordKey) { + return s.runtimeDetectedAgentIds[recordKey] ?? null } return localContextKey ? (s.localDetectedAgentIdsByContext[localContextKey] ?? null) @@ -95,16 +103,16 @@ export function useDetectedAgents( if (targetKind === 'ssh' && targetId) { return s.isDetectingRemoteAgents[targetId] ?? false } - if (targetKind === 'runtime' && targetId) { - return s.isDetectingRuntimeAgents[targetId] ?? false + if (targetKind === 'runtime' && recordKey) { + return s.isDetectingRuntimeAgents[recordKey] ?? false } return localContextKey ? (s.isDetectingLocalAgentsByContext[localContextKey] ?? false) : s.isDetectingAgents }) const isRefreshing = useAppStore((s) => { - if (targetKind === 'runtime' && targetId) { - return s.isRefreshingRuntimeAgents[targetId] ?? false + if (targetKind === 'runtime' && recordKey) { + return s.isRefreshingRuntimeAgents[recordKey] ?? false } if (targetKind === 'ssh' && targetId) { return s.isDetectingRemoteAgents[targetId] ?? false @@ -132,13 +140,13 @@ export function useDetectedAgents( // no-op Zustand subscriptions per hook during unrelated store churn. const state = useAppStore.getState() if (targetKind === 'runtime' && targetId) { - return state.refreshRuntimeDetectedAgents(targetId) + return state.refreshRuntimeDetectedAgents(targetId, runtimeWorktreeId) } if (targetKind === 'ssh' && targetId) { return state.refreshRemoteDetectedAgents(targetId) } return state.refreshDetectedAgents(localWorktreeId) - }, [isUnknown, localWorktreeId, targetKind, targetId]) + }, [isUnknown, localWorktreeId, runtimeWorktreeId, targetKind, targetId]) useEffect(() => { if (isUnknown) { @@ -165,14 +173,14 @@ export function useDetectedAgents( } } else if (targetKind === 'runtime' && targetId) { if (detectedIds === null) { - void state.ensureRuntimeDetectedAgents(targetId) + void state.ensureRuntimeDetectedAgents(targetId, runtimeWorktreeId) } else if (isNewRemoteTarget && detectedIds.length > 0) { // Why: a host can install an agent after its cached list was populated; // refresh once when a new launch surface first observes that host. - void state.refreshRuntimeDetectedAgents(targetId) + void state.refreshRuntimeDetectedAgents(targetId, runtimeWorktreeId) } else if (isNewRemoteTarget) { // Empty results are intentionally retryable through the normal probe. - void state.ensureRuntimeDetectedAgents(targetId) + void state.ensureRuntimeDetectedAgents(targetId, runtimeWorktreeId) } } else { if (detectedIds === null) { @@ -186,6 +194,7 @@ export function useDetectedAgents( remoteTargetKey, detectedIds, localWorktreeId, + runtimeWorktreeId, localContextKey ]) diff --git a/src/renderer/src/lib/agent-detection-target-inventory.ts b/src/renderer/src/lib/agent-detection-target-inventory.ts new file mode 100644 index 00000000000..c2231df2260 --- /dev/null +++ b/src/renderer/src/lib/agent-detection-target-inventory.ts @@ -0,0 +1,48 @@ +import type { AppState } from '@/store/types' +import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents' +import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key' +import type { TuiAgent } from '../../../shared/tui-agent' + +type DetectionReadState = Pick< + AppState, + | 'detectedAgentIds' + | 'localDetectedAgentIdsByContext' + | 'remoteDetectedAgentIds' + | 'runtimeDetectedAgentIds' +> + +type DetectionActionState = Pick< + AppState, + 'ensureDetectedAgents' | 'ensureRemoteDetectedAgents' | 'ensureRuntimeDetectedAgents' +> + +/** The agent list a detection target's host last reported; null while it has not loaded. */ +export function readDetectedAgentsForTarget( + state: DetectionReadState, + target: AgentDetectionTarget +): TuiAgent[] | null { + if (target.kind === 'ssh') { + return state.remoteDetectedAgentIds[target.connectionId] ?? null + } + if (target.kind === 'runtime') { + const key = getRuntimeAgentInventoryKey(target.environmentId, target.worktreeId) + return state.runtimeDetectedAgentIds[key] ?? null + } + return target.contextKey + ? (state.localDetectedAgentIdsByContext[target.contextKey] ?? null) + : state.detectedAgentIds +} + +/** Loads a target's list from the host that owns it. */ +export function ensureDetectedAgentsForTarget( + state: DetectionActionState, + target: AgentDetectionTarget +): Promise { + if (target.kind === 'ssh') { + return state.ensureRemoteDetectedAgents(target.connectionId) + } + if (target.kind === 'runtime') { + return state.ensureRuntimeDetectedAgents(target.environmentId, target.worktreeId) + } + return state.ensureDetectedAgents(target.worktreeId) +} diff --git a/src/renderer/src/lib/empty-workspace-default-agent-chat.ts b/src/renderer/src/lib/empty-workspace-default-agent-chat.ts index ad447daa480..d1494084d9d 100644 --- a/src/renderer/src/lib/empty-workspace-default-agent-chat.ts +++ b/src/renderer/src/lib/empty-workspace-default-agent-chat.ts @@ -1,6 +1,5 @@ import { isNativeChatEnabled } from '../../../shared/structured-native-chat-launch-route' import { pickTuiAgent } from '../../../shared/tui-agent-selection' -import type { TuiAgent } from '../../../shared/tui-agent' import { withTimeout } from '../../../shared/promise-timeout-fallback' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' @@ -9,6 +8,10 @@ import { parseAgentDetectionTargetKey } from '@/hooks/useAgentDetectionTarget' import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents' +import { + ensureDetectedAgentsForTarget, + readDetectedAgentsForTarget +} from '@/lib/agent-detection-target-inventory' import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' @@ -33,24 +36,11 @@ function defaultChatDetectionTarget( return parseAgentDetectionTargetKey(getAgentDetectionTargetKeyForWorktree(state, worktreeId)) } -/** Same host lists the tab bar reads; null means that host's detection has not loaded. */ -function readDetectedAgents(state: AppState, target: AgentDetectionTarget): TuiAgent[] | null { - if (target.kind === 'ssh') { - return state.remoteDetectedAgentIds[target.connectionId] ?? null - } - if (target.kind === 'runtime') { - return state.runtimeDetectedAgentIds[target.environmentId] ?? null - } - return target.contextKey - ? (state.localDetectedAgentIdsByContext[target.contextKey] ?? null) - : state.detectedAgentIds -} - /** True when the default chat could open here but the workspace host's agent list has not loaded. */ export function emptyWorkspaceDefaultChatAwaitsDetection(worktreeId: string): boolean { const state = useAppStore.getState() const target = defaultChatDetectionTarget(state, worktreeId) - return target !== undefined && readDetectedAgents(state, target) === null + return target !== undefined && readDetectedAgentsForTarget(state, target) === null } /** Loads the workspace host's agent list; resolves on success, failure, or timeout. */ @@ -60,13 +50,11 @@ export async function loadEmptyWorkspaceDefaultChatDetection(worktreeId: string) if (!target) { return } - const detection = - target.kind === 'ssh' - ? state.ensureRemoteDetectedAgents(target.connectionId) - : target.kind === 'runtime' - ? state.ensureRuntimeDetectedAgents(target.environmentId) - : state.ensureDetectedAgents(target.worktreeId) - await withTimeout(detection, DEFAULT_CHAT_DETECTION_TIMEOUT_MS, []) + await withTimeout( + ensureDetectedAgentsForTarget(state, target), + DEFAULT_CHAT_DETECTION_TIMEOUT_MS, + [] + ) } /** @@ -85,7 +73,7 @@ export function openDefaultAgentChatInEmptyWorkspace( } const agent = pickTuiAgent( state.settings?.defaultTuiAgent, - readDetectedAgents(state, target) ?? [], + readDetectedAgentsForTarget(state, target) ?? [], state.settings?.disabledTuiAgents ) if (!agent) { diff --git a/src/renderer/src/lib/launch-agent-session-continuation.ts b/src/renderer/src/lib/launch-agent-session-continuation.ts index 6d6861411c2..5bac99ec2cc 100644 --- a/src/renderer/src/lib/launch-agent-session-continuation.ts +++ b/src/renderer/src/lib/launch-agent-session-continuation.ts @@ -28,7 +28,7 @@ export async function detectAgentSessionContinuationAgents( return connectionId ? state.ensureRemoteDetectedAgents(connectionId) : runtimeEnvironmentId - ? state.ensureRuntimeDetectedAgents(runtimeEnvironmentId) + ? state.ensureRuntimeDetectedAgents(runtimeEnvironmentId, worktreeId) : state.ensureDetectedAgents(worktreeId) } diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index c93efe80d83..e508879aafe 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -3,6 +3,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { parseWslUncPath } from '../../../shared/wsl-paths' import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings, + getWorkspaceRuntimePreference, resolveProjectExecutionRuntime, type ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' @@ -81,11 +82,10 @@ export function getLocalProjectExecutionRuntimeContext( } const projectId = getLocalPreflightProjectId(state, worktreeId) const project = getLocalRuntimeProject(state, projectId, repo.id) - const localPath = worktree?.path ?? repo?.path - const worktreeWslDistro = getWslDistroFromPath(localPath) - const projectRuntimePreference = - project?.localWindowsRuntimePreference ?? - (worktreeWslDistro ? { kind: 'wsl', distro: worktreeWslDistro } : { kind: 'inherit-global' }) + const projectRuntimePreference = getWorkspaceRuntimePreference( + project?.localWindowsRuntimePreference, + worktree?.path ?? repo?.path + ) return resolveProjectExecutionRuntime({ appPlatform, @@ -140,10 +140,10 @@ export function getLocalRepoProjectExecutionRuntimeContext( } const project = getLocalRuntimeProject(state, repoId, repo.id) const projectId = project?.id ?? repoId - const repoWslDistro = getWslDistroFromPath(repo?.path) - const projectRuntimePreference = - project?.localWindowsRuntimePreference ?? - (repoWslDistro ? { kind: 'wsl', distro: repoWslDistro } : { kind: 'inherit-global' }) + const projectRuntimePreference = getWorkspaceRuntimePreference( + project?.localWindowsRuntimePreference, + repo?.path + ) return resolveProjectExecutionRuntime({ appPlatform, diff --git a/src/renderer/src/store/slices/runtime-agent-inventory-key.ts b/src/renderer/src/store/slices/runtime-agent-inventory-key.ts new file mode 100644 index 00000000000..ca4b3a03938 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-agent-inventory-key.ts @@ -0,0 +1,15 @@ +const WORKSPACE_SEPARATOR = '\u0000' + +/** A paired host's agent list is per workspace: two projects on one Windows host can run in + * different runtimes (native vs a WSL distro). No workspace means the host-default list. */ +export function getRuntimeAgentInventoryKey( + environmentId: string, + worktreeId?: string | null +): string { + return worktreeId ? `${environmentId}${WORKSPACE_SEPARATOR}${worktreeId}` : environmentId +} + +export function getRuntimeAgentInventoryEnvironmentId(inventoryKey: string): string { + const separatorIndex = inventoryKey.indexOf(WORKSPACE_SEPARATOR) + return separatorIndex === -1 ? inventoryKey : inventoryKey.slice(0, separatorIndex) +} diff --git a/src/renderer/src/store/slices/runtime-detected-agents.ts b/src/renderer/src/store/slices/runtime-detected-agents.ts index daac8ffada0..d990c304d05 100644 --- a/src/renderer/src/store/slices/runtime-detected-agents.ts +++ b/src/renderer/src/store/slices/runtime-detected-agents.ts @@ -1,20 +1,37 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TuiAgent } from '../../../../shared/tui-agent' -import { callRuntimeRpc, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import { + callRuntimeRpc, + RuntimeRpcCallError, + runtimeEnvironmentSupportsCapability +} from '@/runtime/runtime-rpc-client' +import { PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + getRuntimeAgentInventoryEnvironmentId, + getRuntimeAgentInventoryKey +} from './runtime-agent-inventory-key' // Why: remote runtime hosts are not SSH connections, but their launch surfaces // (tab bar, quick launch, Settings → Agents under an Active Server) still have // to probe the host where the workspace actually runs. +// Records are keyed by getRuntimeAgentInventoryKey: the environment alone for +// the host-default list, or environment + workspace for a workspace's list. export type RuntimeDetectedAgentsSlice = { runtimeDetectedAgentIds: Record isDetectingRuntimeAgents: Record isRefreshingRuntimeAgents: Record - ensureRuntimeDetectedAgents: (environmentId: string) => Promise + ensureRuntimeDetectedAgents: ( + environmentId: string, + worktreeId?: string | null + ) => Promise /** Forces a re-detect on the runtime host via `preflight.refreshAgents` * (login-shell PATH re-read), falling back to `preflight.detectAgents` for * servers that predate the refresh RPC. */ - refreshRuntimeDetectedAgents: (environmentId: string) => Promise + refreshRuntimeDetectedAgents: ( + environmentId: string, + worktreeId?: string | null + ) => Promise clearRuntimeDetectedAgents: (environmentId: string) => void /** Drops runtime detected-agent caches for environments not in the kept set. * Wired into setRuntimeEnvironments so removed environments don't leak their @@ -31,208 +48,201 @@ function isRuntimeMethodNotFoundError(error: unknown): boolean { return error instanceof RuntimeRpcCallError && error.code === 'method_not_found' } +/** + * Sends `preflight.detectAgents` / `refreshAgents` scoped to a workspace. A host without the + * workspace-scoped capability would discard the params and answer with its own default, so it is + * asked for exactly that. The host default goes out synchronously, as it always has. + */ +export function callRuntimeAgentDetection( + environmentId: string, + worktreeId: string | null | undefined, + call: (params: { worktreeId: string } | undefined) => Promise +): Promise { + if (!worktreeId) { + return call(undefined) + } + return runtimeEnvironmentSupportsCapability( + environmentId, + PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY + ).then((supported) => call(supported ? { worktreeId } : undefined)) +} + export function _getRuntimeDetectPromiseCountForTest(): number { return runtimeDetectPromises.size } +function withoutKeys(record: Record, drop: (key: string) => boolean) { + let changed = false + const next: Record = {} + for (const [key, value] of Object.entries(record)) { + if (drop(key)) { + changed = true + } else { + next[key] = value + } + } + return changed ? next : record +} + export const createRuntimeDetectedAgentsSlice: StateCreator< AppState, [], [], RuntimeDetectedAgentsSlice -> = (set, get) => ({ - runtimeDetectedAgentIds: {}, - isDetectingRuntimeAgents: {}, - isRefreshingRuntimeAgents: {}, - - ensureRuntimeDetectedAgents: (environmentId: string) => { - const inflightRefresh = runtimeRefreshPromises.get(environmentId) - if (inflightRefresh) { - return inflightRefresh - } - const existing = get().runtimeDetectedAgentIds[environmentId] - // Why: an empty result ([]) is truthy, so a prior "no agents found" detection - // must not be treated as cached — re-detect so a later install / PATH fix is - // picked up without a reconnect. Non-empty results still short-circuit. - if (existing?.length) { - return Promise.resolve(existing) - } - const inflight = runtimeDetectPromises.get(environmentId) - if (inflight) { - return inflight - } - - set((s) => ({ - isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [environmentId]: true } - })) - - const pending = callRuntimeRpc( - { kind: 'environment', environmentId }, - 'preflight.detectAgents' - ) - .then((ids) => { - const typed = ids as TuiAgent[] - // Why: skip committing if the environment was removed (retained out) - // while the detect was in flight — otherwise it re-adds a stale entry - // that retainRuntimeDetectedAgents just pruned. - if (runtimeDetectPromises.get(environmentId) === pending) { - set((s) => ({ - runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [environmentId]: typed }, - isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [environmentId]: false } - })) +> = (set, get) => { + // Drops every workspace's inventory for the environments `drop` selects. + const dropEnvironments = (drop: (environmentId: string) => boolean): void => { + const dropKey = (key: string) => drop(getRuntimeAgentInventoryEnvironmentId(key)) + for (const promises of [runtimeDetectPromises, runtimeRefreshPromises]) { + for (const key of promises.keys()) { + if (dropKey(key)) { + promises.delete(key) } - return typed - }) - .catch(() => { - // Why: a remote runtime may be disconnected or version-incompatible. - // Keep the menu retryable instead of pinning a failed probe forever. - // Same in-flight guard as the .then() above: if the environment was - // retained out mid-detect, don't re-add the isDetecting entry that - // retainRuntimeDetectedAgents just pruned (and don't clobber a freshly - // started detect's spinner). - if (runtimeDetectPromises.get(environmentId) === pending) { - set((s) => ({ - isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [environmentId]: false } - })) - } - return [] as TuiAgent[] - }) - .finally(() => { - if (runtimeDetectPromises.get(environmentId) === pending) { - runtimeDetectPromises.delete(environmentId) - } - }) - - runtimeDetectPromises.set(environmentId, pending) - return pending - }, - - refreshRuntimeDetectedAgents: (environmentId: string) => { - const inflight = runtimeRefreshPromises.get(environmentId) - if (inflight) { - return inflight - } - - // Why: a refresh is newer and authoritative; detach an older detect so its - // late result cannot overwrite the freshly hydrated PATH result. - runtimeDetectPromises.delete(environmentId) - set((s) => ({ - isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [environmentId]: true } - })) - - const pending = callRuntimeRpc<{ agents: TuiAgent[] }>( - { kind: 'environment', environmentId }, - 'preflight.refreshAgents' - ) - .then((result) => result.agents) - .catch((error) => { - if (!isRuntimeMethodNotFoundError(error)) { - throw error - } - // Why: only older servers need the fallback; retrying disconnects and - // runtime failures doubles remote work without any chance of recovery. - return callRuntimeRpc( - { kind: 'environment', environmentId }, - 'preflight.detectAgents' - ) - }) - .then((ids) => { - const typed = ids as TuiAgent[] - // Why: same guard as ensureRuntimeDetectedAgents — if the environment - // was retained out mid-refresh, don't re-add a pruned entry. - if (runtimeRefreshPromises.get(environmentId) === pending) { - set((s) => ({ - runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [environmentId]: typed }, - isDetectingRuntimeAgents: { - ...s.isDetectingRuntimeAgents, - [environmentId]: false - }, - isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [environmentId]: false } - })) - } - return typed - }) - .catch(() => { - // Why: a disconnected runtime must keep Refresh retryable and must not - // wipe the last known agent list. - if (runtimeRefreshPromises.get(environmentId) === pending) { - set((s) => ({ - isDetectingRuntimeAgents: { - ...s.isDetectingRuntimeAgents, - [environmentId]: false - }, - isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [environmentId]: false } - })) - } - return get().runtimeDetectedAgentIds[environmentId] ?? [] - }) - .finally(() => { - if (runtimeRefreshPromises.get(environmentId) === pending) { - runtimeRefreshPromises.delete(environmentId) - } - }) - - runtimeRefreshPromises.set(environmentId, pending) - return pending - }, - - clearRuntimeDetectedAgents: (environmentId: string) => { - runtimeDetectPromises.delete(environmentId) - runtimeRefreshPromises.delete(environmentId) - set((s) => { - const { [environmentId]: _, ...restAgents } = s.runtimeDetectedAgentIds - const { [environmentId]: __, ...restLoading } = s.isDetectingRuntimeAgents - const { [environmentId]: ___, ...restRefreshing } = s.isRefreshingRuntimeAgents - return { - runtimeDetectedAgentIds: restAgents, - isDetectingRuntimeAgents: restLoading, - isRefreshingRuntimeAgents: restRefreshing - } - }) - }, - - retainRuntimeDetectedAgents: (environmentIds: Iterable) => { - const keep = new Set(environmentIds) - for (const id of runtimeDetectPromises.keys()) { - if (!keep.has(id)) { - runtimeDetectPromises.delete(id) - } - } - for (const id of runtimeRefreshPromises.keys()) { - if (!keep.has(id)) { - runtimeRefreshPromises.delete(id) } } set((s) => { - let changed = false - const nextAgents = { ...s.runtimeDetectedAgentIds } - const nextLoading = { ...s.isDetectingRuntimeAgents } - const nextRefreshing = { ...s.isRefreshingRuntimeAgents } - for (const id of Object.keys(nextAgents)) { - if (!keep.has(id)) { - delete nextAgents[id] - changed = true - } - } - for (const id of Object.keys(nextLoading)) { - if (!keep.has(id)) { - delete nextLoading[id] - changed = true - } - } - for (const id of Object.keys(nextRefreshing)) { - if (!keep.has(id)) { - delete nextRefreshing[id] - changed = true - } - } - return changed - ? { - runtimeDetectedAgentIds: nextAgents, - isDetectingRuntimeAgents: nextLoading, - isRefreshingRuntimeAgents: nextRefreshing - } - : s + const runtimeDetectedAgentIds = withoutKeys(s.runtimeDetectedAgentIds, dropKey) + const isDetectingRuntimeAgents = withoutKeys(s.isDetectingRuntimeAgents, dropKey) + const isRefreshingRuntimeAgents = withoutKeys(s.isRefreshingRuntimeAgents, dropKey) + return runtimeDetectedAgentIds === s.runtimeDetectedAgentIds && + isDetectingRuntimeAgents === s.isDetectingRuntimeAgents && + isRefreshingRuntimeAgents === s.isRefreshingRuntimeAgents + ? s + : { runtimeDetectedAgentIds, isDetectingRuntimeAgents, isRefreshingRuntimeAgents } }) } -}) + + return { + runtimeDetectedAgentIds: {}, + isDetectingRuntimeAgents: {}, + isRefreshingRuntimeAgents: {}, + + ensureRuntimeDetectedAgents: (environmentId: string, worktreeId?: string | null) => { + const key = getRuntimeAgentInventoryKey(environmentId, worktreeId) + const inflightRefresh = runtimeRefreshPromises.get(key) + if (inflightRefresh) { + return inflightRefresh + } + const existing = get().runtimeDetectedAgentIds[key] + // Why: an empty result ([]) is truthy, so a prior "no agents found" detection + // must not be treated as cached — re-detect so a later install / PATH fix is + // picked up without a reconnect. Non-empty results still short-circuit. + if (existing?.length) { + return Promise.resolve(existing) + } + const inflight = runtimeDetectPromises.get(key) + if (inflight) { + return inflight + } + + set((s) => ({ + isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: true } + })) + + const target = { kind: 'environment', environmentId } as const + const pending = callRuntimeAgentDetection(environmentId, worktreeId, (params) => + callRuntimeRpc(target, 'preflight.detectAgents', params) + ) + .then((typed) => { + // Why: skip committing if the environment was removed (retained out) + // while the detect was in flight — otherwise it re-adds a stale entry + // that retainRuntimeDetectedAgents just pruned. + if (runtimeDetectPromises.get(key) === pending) { + set((s) => ({ + runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [key]: typed }, + isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: false } + })) + } + return typed + }) + .catch((): TuiAgent[] => { + // Why: a remote runtime may be disconnected or version-incompatible. + // Keep the menu retryable instead of pinning a failed probe forever. + // Same in-flight guard as the .then() above. + if (runtimeDetectPromises.get(key) === pending) { + set((s) => ({ + isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: false } + })) + } + return [] + }) + .finally(() => { + if (runtimeDetectPromises.get(key) === pending) { + runtimeDetectPromises.delete(key) + } + }) + + runtimeDetectPromises.set(key, pending) + return pending + }, + + refreshRuntimeDetectedAgents: (environmentId: string, worktreeId?: string | null) => { + const key = getRuntimeAgentInventoryKey(environmentId, worktreeId) + const inflight = runtimeRefreshPromises.get(key) + if (inflight) { + return inflight + } + + // Why: a refresh is newer and authoritative; detach an older detect so its + // late result cannot overwrite the freshly hydrated PATH result. + runtimeDetectPromises.delete(key) + set((s) => ({ + isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [key]: true } + })) + + const target = { kind: 'environment', environmentId } as const + const pending = callRuntimeAgentDetection(environmentId, worktreeId, (params) => + callRuntimeRpc<{ agents: TuiAgent[] }>(target, 'preflight.refreshAgents', params) + .then((result) => result.agents) + .catch((error) => { + if (!isRuntimeMethodNotFoundError(error)) { + throw error + } + // Why: only older servers need the fallback; retrying disconnects and + // runtime failures doubles remote work without any chance of recovery. + return callRuntimeRpc(target, 'preflight.detectAgents', params) + }) + ) + .then((typed) => { + // Why: same guard as ensureRuntimeDetectedAgents — if the environment + // was retained out mid-refresh, don't re-add a pruned entry. + if (runtimeRefreshPromises.get(key) === pending) { + set((s) => ({ + runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [key]: typed }, + isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: false }, + isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [key]: false } + })) + } + return typed + }) + .catch(() => { + // Why: a disconnected runtime must keep Refresh retryable and must not + // wipe the last known agent list. + if (runtimeRefreshPromises.get(key) === pending) { + set((s) => ({ + isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [key]: false }, + isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [key]: false } + })) + } + return get().runtimeDetectedAgentIds[key] ?? [] + }) + .finally(() => { + if (runtimeRefreshPromises.get(key) === pending) { + runtimeRefreshPromises.delete(key) + } + }) + + runtimeRefreshPromises.set(key, pending) + return pending + }, + + clearRuntimeDetectedAgents: (environmentId: string) => { + dropEnvironments((id) => id === environmentId) + }, + + retainRuntimeDetectedAgents: (environmentIds: Iterable) => { + const keep = new Set(environmentIds) + dropEnvironments((id) => !keep.has(id)) + } + } +} diff --git a/src/shared/project-execution-runtime.ts b/src/shared/project-execution-runtime.ts index 6e80c1e8dbb..9a9eade6c49 100644 --- a/src/shared/project-execution-runtime.ts +++ b/src/shared/project-execution-runtime.ts @@ -1,3 +1,5 @@ +import { parseWslUncPath } from './wsl-paths' + export type LocalWindowsRuntimePreference = | { kind: 'inherit-global' } | { kind: 'windows-host' } @@ -107,6 +109,18 @@ export function normalizeProjectRuntimePreference(value: unknown): LocalWindowsR return { kind: 'inherit-global' } } +/** A project with no saved runtime runs a workspace that lives in a WSL share inside that distro. */ +export function getWorkspaceRuntimePreference( + projectPreference: LocalWindowsRuntimePreference | undefined, + workspacePath: string | null | undefined +): LocalWindowsRuntimePreference { + if (projectPreference) { + return projectPreference + } + const distro = workspacePath ? parseWslUncPath(workspacePath)?.distro : undefined + return distro ? { kind: 'wsl', distro } : { kind: 'inherit-global' } +} + export function normalizeGlobalWindowsRuntimeDefault(value: unknown): GlobalWindowsRuntimeDefault { if (!isRecord(value)) { return { kind: 'windows-host' } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 36bec54bece..3433e1a2496 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -320,6 +320,10 @@ export const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY = // would re-show that row as a normal workspace, so the host leaves such rows out of its listings. export const WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY = 'worktree.background-removal.v1' as const +// Why: older hosts discard preflight.detectAgents params and probe their own PATH, so a client +// must not read that answer as the workspace's (a WSL project on a Windows host differs). +export const PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY = + 'preflight.workspace-scoped.v1' as const // Hosts without this capability have no notifications.registerPush RPC. export const NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1' as const @@ -425,6 +429,7 @@ export const RUNTIME_CAPABILITIES = [ ...SKILL_RUNTIME_CAPABILITIES, ...AUTOMATION_RUNTIME_CAPABILITIES, NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, + PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY, ...AGENT_LAUNCH_RUNTIME_CAPABILITIES ] as const diff --git a/src/shared/rpc-contract/preflight-params.ts b/src/shared/rpc-contract/preflight-params.ts index 3dc6a8d1ef2..ba282ff4842 100644 --- a/src/shared/rpc-contract/preflight-params.ts +++ b/src/shared/rpc-contract/preflight-params.ts @@ -11,3 +11,8 @@ export const PreflightDetectRemoteAgents = z.object({ export const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({ connectionId: z.string().min(1) }) + +/** Why optional: a client that predates it sends nothing and keeps the host-default probe. */ +export const PreflightAgentDetection = z.object({ + worktreeId: z.string().min(1).optional() +}) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index be79a84511a..4078f0bbb1c 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -433,6 +433,7 @@ import { PluginsPanelActionParams } from './plugins-params' import { + PreflightAgentDetection, PreflightCheck, PreflightDetectRemoteAgents, PreflightDetectRemoteWindowsTerminalCapabilities @@ -1110,11 +1111,11 @@ export const RPC_PARAMS_BY_METHOD = { 'plugins.readPanelEntry': PluginReadPanelEntryParams, 'plugins.setEnabled': PluginSetEnabledParams, 'preflight.check': PreflightCheck, - 'preflight.detectAgents': null, + 'preflight.detectAgents': PreflightAgentDetection, 'preflight.detectRemoteAgents': PreflightDetectRemoteAgents, 'preflight.detectRemoteWindowsTerminalCapabilities': PreflightDetectRemoteWindowsTerminalCapabilities, - 'preflight.refreshAgents': null, + 'preflight.refreshAgents': PreflightAgentDetection, 'project.list': null, 'project.update': ProjectUpdate, 'projectGroup.create': ProjectGroupCreate, diff --git a/tests/e2e/cross-version-wire/preflight-workspace-scoped-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/preflight-workspace-scoped-downgrade.unit.test.ts new file mode 100644 index 00000000000..75099b3ed67 --- /dev/null +++ b/tests/e2e/cross-version-wire/preflight-workspace-scoped-downgrade.unit.test.ts @@ -0,0 +1,187 @@ +// Workspace-scoped agent detection, paired across two builds: current code against the newest +// published release. +// +// `preflight.detectAgents` / `refreshAgents` gained an optional `{ worktreeId }` so the host probes +// the workspace's own runtime (a WSL distro on a Windows host). Each skew must keep today's answer: +// +// - an old client sends nothing, and a new host answers with its own default as it always did; +// - a new client asks an old host only for its default, because the capability is absent — and an +// old host that is sent the field anyway (mobile does not gate) discards it rather than refusing. +// +// The old side's capability list is read from its checkout and the capability removed from it, so +// this stays exercised after a release ships the capability. + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY, + RUNTIME_CAPABILITIES +} from '../../../src/shared/protocol-version' +import { RPC_PARAMS_BY_METHOD } from '../../../src/shared/rpc-contract/rpc-params-catalog.generated' +import { parseRpcRequestParams } from '../../../src/main/runtime/rpc/dispatcher-request-parsing' +import type { RpcAnyMethodDeclaration, RpcRequest } from '../../../src/main/runtime/rpc/core' +import { getRuntimeAgentInventoryKey } from '../../../src/renderer/src/store/slices/runtime-agent-inventory-key' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 +const WORKTREE = 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + +type ParseParams = ( + request: RpcRequest, + method: Pick, + meta: unknown +) => { value?: unknown; error?: unknown } + +type HostBuild = { + capabilities: readonly string[] + parse: (method: string, params: unknown) => unknown +} + +const wire = vi.hoisted(() => { + const state: { + host: null | { capabilities: readonly string[]; parse: (m: string, p: unknown) => unknown } + sent: { method: string; params: unknown; hostSaw: unknown }[] + } = { host: null, sent: [] } + return state +}) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + runtimeEnvironmentSupportsCapability: async (_environmentId: string, capability: string) => + wire.host?.capabilities.includes(capability) ?? false, + callRuntimeRpc: async (_target: unknown, method: string, params?: unknown) => { + const hostSaw = wire.host?.parse(method, params) + wire.sent.push({ method, params, hostSaw }) + const scoped = typeof hostSaw === 'object' && hostSaw !== null && 'worktreeId' in hostSaw + const agents = scoped ? ['claude', 'codex'] : ['claude'] + return method === 'preflight.refreshAgents' ? { agents } : agents + } + } +}) + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: {} } + +const { createTestStore } = + await import('../../../src/renderer/src/store/slices/store-test-helpers') + +function member(module: Record, name: string): T { + const value = module[name] + if (value === undefined) { + throw new Error(`the baseline release exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a release module is typed unknown; the name is the same export this build calls, and a missing one throws above. + return value as T +} + +function hostBuild( + capabilities: readonly string[], + catalog: Record, + parse: ParseParams +): HostBuild { + return { + capabilities, + parse: (method, params) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: catalog entries are the Zod schema or null each build's dispatcher reads. + const schema = (catalog[method] ?? null) as RpcAnyMethodDeclaration['params'] + const parsed = parse({ id: '1', authToken: 't', method, params }, { params: schema }, {}) + if (parsed.error) { + throw new Error(`${method} refused ${JSON.stringify(params)}`) + } + return parsed.value + } + } +} + +let oldHost: HostBuild +let newHost: HostBuild + +beforeAll(async () => { + const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) + const [protocol, catalog, parsing] = await Promise.all([ + importReleaseCheckoutModule(checkout, 'src/shared/protocol-version.ts'), + importReleaseCheckoutModule( + checkout, + 'src/shared/rpc-contract/rpc-params-catalog.generated.ts' + ), + importReleaseCheckoutModule(checkout, 'src/main/runtime/rpc/dispatcher-request-parsing.ts') + ]) + const oldCatalog = member>(catalog, 'RPC_PARAMS_BY_METHOD') + oldHost = hostBuild( + member(protocol, 'RUNTIME_CAPABILITIES').filter( + (capability) => capability !== PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY + ), + // Why null: once a release ships the schema, the old side must still be the pre-schema host. + { ...oldCatalog, 'preflight.detectAgents': null, 'preflight.refreshAgents': null }, + member(parsing, 'parseRpcRequestParams') + ) + newHost = hostBuild(RUNTIME_CAPABILITIES, RPC_PARAMS_BY_METHOD, (request, method, meta) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the parser only reads `params` from the declaration. + parseRpcRequestParams(request, method as RpcAnyMethodDeclaration, meta as never) + ) +}, SUITE_TIMEOUT_MS) + +beforeEach(() => { + wire.sent.length = 0 +}) + +describe('workspace-scoped agent detection across versions', () => { + it('a new client asks an old host only for its default list', async () => { + wire.host = oldHost + const store = createTestStore() + + const detected = await store.getState().ensureRuntimeDetectedAgents('env-1', WORKTREE) + const refreshed = await store.getState().refreshRuntimeDetectedAgents('env-1', WORKTREE) + + expect(wire.sent.map(({ method, params }) => ({ method, params }))).toEqual([ + { method: 'preflight.detectAgents', params: undefined }, + { method: 'preflight.refreshAgents', params: undefined } + ]) + expect(detected).toEqual(['claude']) + expect(refreshed).toEqual(['claude']) + expect( + store.getState().runtimeDetectedAgentIds[getRuntimeAgentInventoryKey('env-1', WORKTREE)] + ).toEqual(['claude']) + }) + + it('an old host discards the field from an ungated client instead of refusing it', () => { + expect(oldHost.parse('preflight.detectAgents', { worktreeId: WORKTREE })).toBeUndefined() + expect(oldHost.parse('preflight.refreshAgents', { worktreeId: WORKTREE })).toBeUndefined() + }) + + it('a new client names the workspace to a new host', async () => { + wire.host = newHost + const store = createTestStore() + + expect(await store.getState().ensureRuntimeDetectedAgents('env-1', WORKTREE)).toEqual([ + 'claude', + 'codex' + ]) + expect(wire.sent).toEqual([ + { + method: 'preflight.detectAgents', + params: { worktreeId: WORKTREE }, + hostSaw: { worktreeId: WORKTREE } + } + ]) + }) + + it('an old client gets the host default from a new host', async () => { + wire.host = newHost + for (const params of [undefined, null]) { + expect(newHost.parse('preflight.detectAgents', params)).toEqual({}) + expect(newHost.parse('preflight.refreshAgents', params)).toEqual({}) + } + // The host-default list keeps its environment-only key, which existing readers index by. + const store = createTestStore() + expect(await store.getState().ensureRuntimeDetectedAgents('env-1')).toEqual(['claude']) + expect(wire.sent[0]?.params).toBeUndefined() + expect(store.getState().runtimeDetectedAgentIds['env-1']).toEqual(['claude']) + }) +})