diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 9afc865f87e..8a648a1e4ba 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -12,6 +12,20 @@ on: # Why: the mobile terminal link parsers are conformance-tested against # these shared fixtures; desktop-side fixture edits must re-run this suite. - 'src/shared/terminal-file-link-conformance.ts' + # Why: mobile imports the negotiated capability names directly and records + # the whole capability read verbatim in its goldens, so a capability added + # desktop-side rewrites a mobile fixture and must re-run this suite. + - 'src/shared/protocol-version.ts' + # Why: mobile's rpc-params-contract.ts is a type-only re-export of the + # generated params catalog, and mobile/tsconfig.json includes **/*.ts. A + # schema edit anywhere under here changes mobile's types, so a desktop-only + # change can break mobile's typecheck with no other mobile signal. + - 'src/shared/rpc-contract/**' + # Why: the catalog above holds params only. This file is the sole holder of + # the agent.launch RESULT shape, and mobile imports it as a value, not just + # a type. CROSS_VERSION_WIRE_PREFIXES already treats it as wire-critical, so + # without this one gate classes it that way while this one cannot see it. + - 'src/shared/agent-launch-intent.ts' # Why: this job holds the only checks that load the Fastfile, so edits to # it or to the release workflow it guards must re-run them. - '.github/workflows/mobile.yml' diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 3d412df7ba8..428379bd28e 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -113,6 +113,8 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/shared/browser-client-host-protocol', 'src/shared/browser-network-tunnel-protocol', 'src/shared/browser-client-host-placement', + 'src/shared/agent-launch-intent', + 'src/shared/rpc-contract/agent-launch-params', 'src/shared/agent-session-wire', 'src/shared/agent-session-mutation-envelope', 'src/shared/agent-session-journal-', @@ -121,6 +123,7 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/native-chat/agent-session-wire/', 'src/main/runtime/agent-session-record-store', 'src/main/runtime/rpc/dispatcher', + 'src/main/runtime/rpc/methods/agent-launch', 'src/main/runtime/rpc/methods/ai-vault.ts', 'src/main/runtime/rpc/methods/browser-tab-create-schema', 'src/main/runtime/rpc/methods/session-tabs.ts', diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json index 0367d6f4c5d..df037b682c1 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json @@ -6,7 +6,7 @@ "baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", "recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e", - "adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599", + "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "7e4c5bb29e0f630cda8a09233575b9295e485f3d3e315ebdc0458c69515fcfc7", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json index 979822c0cda..6f7a2539088 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json @@ -6,7 +6,7 @@ "baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", "recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e", - "adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599", + "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "1d7713cf4c23d053105c2abb02340d81d5eb689f4311a0984932d8ebd031b4ce", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json index 10868fbd02c..3271dd35053 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json @@ -6,7 +6,7 @@ "baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", "recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e", - "adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599", + "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "994ea8b4ddb05774a8c2d5902bb68bf5e8f25399a787262b8f23f458f2790698", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json index a2571e10595..4938512d471 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json @@ -6,7 +6,7 @@ "baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", "recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e", - "adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599", + "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "a89bdf93df71a958810aba72c80e42f663644781a29e934898e2ddf86c5dd5d5", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json index ece3b1f3a61..22ae2f887e4 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json @@ -89,16 +89,6 @@ } } }, - "199931225ca2": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "TypeError", - "message": "Cannot read properties of undefined (reading 'displayName')", - "isRpcDeliveryUnknown": false - } - }, "240b0b1c72b2": { "status": "fulfilled", "startedAt": 0, @@ -107,16 +97,6 @@ "error": "" } }, - "2588fd63a157": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "TypeError", - "message": "Cannot read properties of undefined (reading 'worktree')", - "isRpcDeliveryUnknown": false - } - }, "292579caa07d": { "name": "worktree.create#1", "args": [ @@ -354,6 +334,14 @@ } } }, + "9df0ac2b0247": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": "Failed to create workspace" + } + }, "a947768bc0ed": { "status": "rejected", "startedAt": 0, @@ -373,16 +361,6 @@ "worktreeId": "repo-1::/w" } }, - "b5447f4dd931": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "TypeError", - "message": "Cannot read properties of null (reading 'worktree')", - "isRpcDeliveryUnknown": false - } - }, "b6ebedadd49b": { "name": "worktree.create#1", "args": [ @@ -512,6 +490,11 @@ "name": "kestrel", "worktreeId": "repo-1::/w" } + }, + "ed1eb862d036": { + "outcome": { + "error": "Failed to create workspace" + } } }, "recording": { @@ -535,9 +518,9 @@ "sender": ["cf574d8c995b"], "payloads": ["de75bbf6c762"], "settlements": { - "create": "2588fd63a157" + "create": "9df0ac2b0247" }, - "state": "3f946ad0279c", + "state": "ed1eb862d036", "effects": [] } }, @@ -547,9 +530,9 @@ "sender": ["0938d32a2ec2"], "payloads": ["de75bbf6c762"], "settlements": { - "create": "b5447f4dd931" + "create": "9df0ac2b0247" }, - "state": "3f946ad0279c", + "state": "ed1eb862d036", "effects": [] } }, @@ -559,9 +542,9 @@ "sender": ["6bf7db287168"], "payloads": ["de75bbf6c762"], "settlements": { - "create": "199931225ca2" + "create": "9df0ac2b0247" }, - "state": "3f946ad0279c", + "state": "ed1eb862d036", "effects": [] } }, @@ -571,9 +554,9 @@ "sender": ["96a4c62e654d"], "payloads": ["de75bbf6c762"], "settlements": { - "create": "199931225ca2" + "create": "9df0ac2b0247" }, - "state": "3f946ad0279c", + "state": "ed1eb862d036", "effects": [] } }, @@ -583,9 +566,9 @@ "sender": ["2e78a1dad2ea"], "payloads": ["de75bbf6c762"], "settlements": { - "create": "199931225ca2" + "create": "9df0ac2b0247" }, - "state": "3f946ad0279c", + "state": "ed1eb862d036", "effects": [] } }, diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json index eb8c80244a4..c9945349007 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json @@ -13,6 +13,31 @@ "projectionVersion": 2, "goldenFormatVersion": 5, "values": { + "041e5e32563c": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "agentLaunch": false, + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": false, + "worktreeCreateIdempotency": false + } + }, + "120979f40a68": { + "capabilities": { + "agentLaunch": false, + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": true, + "worktreeCreateIdempotency": { + "dedupeTtlMs": 45000 + } + } + }, "16cd464bf664": { "name": "status.get#1", "args": [ @@ -78,6 +103,21 @@ } } }, + "3a7704ccec26": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "agentLaunch": false, + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": true, + "worktreeCreateIdempotency": { + "dedupeTtlMs": 45000 + } + } + }, "4451bb95a76e": { "name": "status.get#1", "args": [ @@ -148,17 +188,6 @@ } } }, - "62aaf19f0b16": { - "capabilities": { - "hostPlatform": { - "$rpc": "null" - }, - "tasksSupported": true, - "worktreeCreateIdempotency": { - "dedupeTtlMs": 45000 - } - } - }, "7d3dd7f9381b": { "name": "status.get#1", "args": [ @@ -194,18 +223,6 @@ "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}", "sent": 1 }, - "86f7fa8089fe": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "hostPlatform": { - "$rpc": "null" - }, - "tasksSupported": false, - "worktreeCreateIdempotency": false - } - }, "88200d49083c": { "name": "status.get#1", "args": [ @@ -343,20 +360,6 @@ } } }, - "b33d34bddc4e": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "hostPlatform": { - "$rpc": "null" - }, - "tasksSupported": true, - "worktreeCreateIdempotency": { - "dedupeTtlMs": 45000 - } - } - }, "c71b2f8a6993": { "name": "status.get#1", "args": [ @@ -422,8 +425,9 @@ } } }, - "f80e92134eb1": { + "f793cb0dfb40": { "capabilities": { + "agentLaunch": false, "hostPlatform": { "$rpc": "null" }, @@ -441,9 +445,9 @@ "sender": ["5242fad3532f"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "b33d34bddc4e" + "probe": "3a7704ccec26" }, - "state": "62aaf19f0b16", + "state": "120979f40a68", "effects": [] } }, @@ -453,9 +457,9 @@ "sender": ["7d3dd7f9381b"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "86f7fa8089fe" + "probe": "041e5e32563c" }, - "state": "f80e92134eb1", + "state": "f793cb0dfb40", "effects": [] } }, @@ -465,9 +469,9 @@ "sender": ["88200d49083c"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "86f7fa8089fe" + "probe": "041e5e32563c" }, - "state": "f80e92134eb1", + "state": "f793cb0dfb40", "effects": [] } }, @@ -477,9 +481,9 @@ "sender": ["4451bb95a76e"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "86f7fa8089fe" + "probe": "041e5e32563c" }, - "state": "f80e92134eb1", + "state": "f793cb0dfb40", "effects": [] } }, @@ -489,9 +493,9 @@ "sender": ["944bf432f199"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "86f7fa8089fe" + "probe": "041e5e32563c" }, - "state": "f80e92134eb1", + "state": "f793cb0dfb40", "effects": [] } }, @@ -501,9 +505,9 @@ "sender": ["89236e432861"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "86f7fa8089fe" + "probe": "041e5e32563c" }, - "state": "f80e92134eb1", + "state": "f793cb0dfb40", "effects": [] } }, @@ -513,9 +517,9 @@ "sender": ["16cd464bf664"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "86f7fa8089fe" + "probe": "041e5e32563c" }, - "state": "f80e92134eb1", + "state": "f793cb0dfb40", "effects": [] } }, @@ -525,9 +529,9 @@ "sender": ["9cdf3c107e7b"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "86f7fa8089fe" + "probe": "041e5e32563c" }, - "state": "f80e92134eb1", + "state": "f793cb0dfb40", "effects": [] } }, @@ -537,9 +541,9 @@ "sender": ["c71b2f8a6993"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "86f7fa8089fe" + "probe": "041e5e32563c" }, - "state": "f80e92134eb1", + "state": "f793cb0dfb40", "effects": [] } }, @@ -549,9 +553,9 @@ "sender": ["de87f6266897"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "86f7fa8089fe" + "probe": "041e5e32563c" }, - "state": "f80e92134eb1", + "state": "f793cb0dfb40", "effects": [] } }, @@ -561,9 +565,9 @@ "sender": ["2698c9770ad3"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "86f7fa8089fe" + "probe": "041e5e32563c" }, - "state": "f80e92134eb1", + "state": "f793cb0dfb40", "effects": [] } } diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json index 8a06e1db4c3..f45cdfa49aa 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json @@ -6,7 +6,7 @@ "baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", "recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e", - "adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599", + "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "662c3e04e31bce5757f09f91e3e3739fb9d57767b7443be4dc936705b64b1432", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json index 698f9671155..0a6313c077e 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json @@ -6,7 +6,7 @@ "baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", "recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e", - "adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599", + "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "8ae9e1dbb32d404eac9e01f71dacf1c37497030220a8e988c0093bb7ed2d159b", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json index 0760be42158..519cd8ed752 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json @@ -6,7 +6,7 @@ "baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", "recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e", - "adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599", + "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "5c4c890e4c71e80fa8847a5e29700fc9df3ac3bd634bad6289db37522fadd621", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json index 1e108c2c928..e6348f3806e 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json @@ -6,7 +6,7 @@ "baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", "recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e", - "adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599", + "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "a699a0a5b128fa422dab0c7557b5aa18599b2d23fa6685cdcc02e17edf328af1", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json index ef58b5f3909..a993b9367e5 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json @@ -6,7 +6,7 @@ "baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", "recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e", - "adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599", + "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "a5e812cd508826b3f01ec3798c621ab4303de6536a364113f01a4770dd197bb5", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json index 8d5a6ca28e0..b0ab7c53a3b 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json @@ -6,7 +6,7 @@ "baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", "recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e", - "adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599", + "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "b10ff86086c134284cb0446e8857cd4b55f5ff2bd0507388ec659a95f25e2a19", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json index ad7432dca0f..f43b428fe16 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json @@ -6,7 +6,7 @@ "baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", "recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e", - "adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599", + "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "373ea3743dac4e0845df01d5c8f75c909563b8c517f3858293a478234dc9ca5c", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json index e5a12f8d178..07a594367f4 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json @@ -6,7 +6,7 @@ "baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", "recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e", - "adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599", + "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "dfbacbd6392ae8e8199550952fe917e7c01182349df6c99a06eb0682cfd9175c", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json index e259b9e87e2..11e736e2de9 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json @@ -13,6 +13,33 @@ "projectionVersion": 2, "goldenFormatVersion": 5, "values": { + "120979f40a68": { + "capabilities": { + "agentLaunch": false, + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": true, + "worktreeCreateIdempotency": { + "dedupeTtlMs": 45000 + } + } + }, + "3a7704ccec26": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "agentLaunch": false, + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": true, + "worktreeCreateIdempotency": { + "dedupeTtlMs": 45000 + } + } + }, "5242fad3532f": { "name": "status.get#1", "args": [ @@ -50,35 +77,10 @@ } } }, - "62aaf19f0b16": { - "capabilities": { - "hostPlatform": { - "$rpc": "null" - }, - "tasksSupported": true, - "worktreeCreateIdempotency": { - "dedupeTtlMs": 45000 - } - } - }, "852980e2efc0": { "name": "status.get#1", "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}", "sent": 1 - }, - "b33d34bddc4e": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "hostPlatform": { - "$rpc": "null" - }, - "tasksSupported": true, - "worktreeCreateIdempotency": { - "dedupeTtlMs": 45000 - } - } } }, "recording": { @@ -90,9 +92,9 @@ "sender": ["5242fad3532f"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "b33d34bddc4e" + "probe": "3a7704ccec26" }, - "state": "62aaf19f0b16", + "state": "120979f40a68", "effects": [] } } diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json index e5afa775843..076756e352e 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json @@ -16,29 +16,14 @@ "32354557bece": { "capabilities": "unprobed" }, - "4e6e53404f59": { - "capabilities": { - "hostPlatform": { - "$rpc": "null" - }, - "tasksSupported": true, - "worktreeCreateIdempotency": false - } - }, "852980e2efc0": { "name": "status.get#1", "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}", "sent": 1 }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "a8bcef1e95ed": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { + "8a8da145ef52": { + "capabilities": { + "agentLaunch": false, "hostPlatform": { "$rpc": "null" }, @@ -46,6 +31,10 @@ "worktreeCreateIdempotency": false } }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, "ae9ff6b74ec1": { "name": "status.get#2", "args": [ @@ -84,6 +73,19 @@ "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}", "sent": 2 }, + "c7a06214cf1f": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "agentLaunch": false, + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": true, + "worktreeCreateIdempotency": false + } + }, "c9c0513fdcb9": { "name": "status.get#2", "args": [ @@ -176,10 +178,10 @@ "sender": ["edf54746317d", "ae9ff6b74ec1"], "payloads": ["852980e2efc0", "b33a14df0df6"], "settlements": { - "probe": "a8bcef1e95ed", + "probe": "c7a06214cf1f", "migrate": "eb79a9b3682a" }, - "state": "4e6e53404f59", + "state": "8a8da145ef52", "effects": [] } } diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json index 320eef01b34..3b68ddec20c 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json @@ -13,31 +13,6 @@ "projectionVersion": 2, "goldenFormatVersion": 5, "values": { - "03f6a4ac937a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "hostPlatform": { - "$rpc": "null" - }, - "tasksSupported": false, - "worktreeCreateIdempotency": { - "dedupeTtlMs": 60000 - } - } - }, - "3b0c9705ec9a": { - "capabilities": { - "hostPlatform": { - "$rpc": "null" - }, - "tasksSupported": false, - "worktreeCreateIdempotency": { - "dedupeTtlMs": 60000 - } - } - }, "488c988b5918": { "name": "status.get#1", "args": [ @@ -75,6 +50,33 @@ "name": "status.get#1", "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}", "sent": 1 + }, + "ab209a152529": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "agentLaunch": false, + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": false, + "worktreeCreateIdempotency": { + "dedupeTtlMs": 60000 + } + } + }, + "f079a530dc44": { + "capabilities": { + "agentLaunch": false, + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": false, + "worktreeCreateIdempotency": { + "dedupeTtlMs": 60000 + } + } } }, "recording": { @@ -86,9 +88,9 @@ "sender": ["488c988b5918"], "payloads": ["852980e2efc0"], "settlements": { - "probe": "03f6a4ac937a" + "probe": "ab209a152529" }, - "state": "3b0c9705ec9a", + "state": "f079a530dc44", "effects": [] } } diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 511b8276be5..0e343f589e0 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -74,7 +74,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { const [note, setNote] = useState('') const [error, setError] = useState('') const runtime = useNewWorkspaceRuntimeContext(client, visible, hostId) - const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } = + const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport, getAgentLaunchSupport } = useNewWorktreeRuntimeCapabilities(client, visible) const selectedRepoConnectionId = selectedRepo?.connectionId ?? null const executionTarget = useNewWorkspaceExecutionTarget({ @@ -129,6 +129,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { trustedOrcaHooks: runtime.trustedOrcaHooks, setTrustedOrcaHooks: runtime.setTrustedOrcaHooks, getWorktreeCreateCutoverSupport, + getAgentLaunchSupport, transitionDrawer: navigation.transitionDrawer, setError, onCreated, diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts index c9265f8ed21..fe697ef7b9c 100644 --- a/mobile/src/components/use-new-workspace-create-submit.ts +++ b/mobile/src/components/use-new-workspace-create-submit.ts @@ -56,6 +56,7 @@ export function useNewWorkspaceCreateSubmit(args: { trustedOrcaHooks: PersistedTrustedOrcaHooks setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void getWorktreeCreateCutoverSupport: () => Promise + getAgentLaunchSupport: () => Promise transitionDrawer: (view: Exclude) => void setError: Dispatch> onCreated: (worktreeId: string, name: string, warning?: string) => void @@ -163,7 +164,8 @@ export function useNewWorkspaceCreateSubmit(args: { workspaceName: trimmedName || undefined, note: trimmedNote, nameIsAutoManaged: args.composer.isNameAutoManaged, - worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport() + worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport(), + agentLaunchSupported: args.getAgentLaunchSupport() }) : await createBlankWorkspace({ client, @@ -173,7 +175,8 @@ export function useNewWorkspaceCreateSubmit(args: { createdWithAgentId, comment: trimmedNote, setupDecision, - worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport() + worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport(), + agentLaunchSupported: args.getAgentLaunchSupport() }) if ('error' in result) { args.setError(result.error) diff --git a/mobile/src/session/active-session-tab.test.ts b/mobile/src/session/active-session-tab.test.ts index ec3a6fa5ee3..390e947358e 100644 --- a/mobile/src/session/active-session-tab.test.ts +++ b/mobile/src/session/active-session-tab.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { resolveActiveSessionTab } from './active-session-tab' -type Tab = { id: string; type: 'terminal' | 'browser'; isActive: boolean } +type Tab = { id: string; type: 'terminal' | 'browser' | 'agent-session'; isActive: boolean } function terminalTab(id: string, isActive: boolean): Tab { return { id, type: 'terminal', isActive } @@ -135,6 +135,23 @@ describe('resolveActiveSessionTab', () => { expect(result.selectionSource).toBe('snapshot') }) + it('opens the chat a create-worktree launch landed on, over the workspace shell', () => { + // Why: a workspace created from the mobile sheet with an agent arrives on a fresh route with + // no device pick, so the host's own activation is the only thing that says "open the chat". + // `agent.launch` publishes and activates that tab before it answers, so it is already in the + // first snapshot the route fetches. + const result = resolveActiveSessionTab( + [ + { id: 'shell', type: 'terminal', isActive: false }, + { id: 'agent-session:s-1', type: 'agent-session', isActive: true } + ], + { pendingActiveSessionTabId: null, selectedSessionTabId: null } + ) + + expect(result.activeTab?.id).toBe('agent-session:s-1') + expect(result.selectionSource).toBe('snapshot') + }) + it('returns null for an empty snapshot', () => { expect( resolveActiveSessionTab([], { pendingActiveSessionTabId: null, selectedSessionTabId: 'x' }) diff --git a/mobile/src/tasks/agent-launch-worktree-create.test.ts b/mobile/src/tasks/agent-launch-worktree-create.test.ts new file mode 100644 index 00000000000..8b2567919c3 --- /dev/null +++ b/mobile/src/tasks/agent-launch-worktree-create.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { + agentLaunchCreateParams, + isAgentLaunchUnsupportedRefusal, + readAgentLaunchCreateOutcome +} from './agent-launch-worktree-create' + +describe('agentLaunchCreateParams', () => { + it('carries the create payload verbatim minus the reserved agent fields', () => { + // Why: the launch owns placement. A `startupAgent` left in the payload would create the + // worktree agent-first again, which is exactly the path this method exists to replace. + expect( + agentLaunchCreateParams('codex', { + repo: 'id:repo-1', + name: 'otter', + setupDecision: 'run', + comment: 'spike', + clientMutationId: 'k-1', + startupAgent: 'codex', + startupDraft: 'https://example.test/issues/1', + createdWithAgent: 'codex' + }) + ).toEqual({ + agent: 'codex', + target: { + kind: 'create-worktree', + create: { + repo: 'id:repo-1', + name: 'otter', + setupDecision: 'run', + comment: 'spike', + clientMutationId: 'k-1', + createdWithAgent: 'codex' + } + } + }) + }) +}) + +describe('readAgentLaunchCreateOutcome', () => { + it.each([ + { + label: 'structured', + result: { + worktreeId: 'wt-1', + outcome: { kind: 'structured', sessionId: 's-1', handle: 'agent-session:s-1' } + } + }, + { + label: 'terminal', + result: { worktreeId: 'wt-1', outcome: { kind: 'terminal', handle: 'term-1' } } + } + ])('reads the created workspace out of a $label receipt', ({ result }) => { + expect(readAgentLaunchCreateOutcome(result)).toEqual({ worktreeId: 'wt-1' }) + }) + + it.each([null, 'wt-1', {}, { worktreeId: '' }, { worktreeId: 7 }])( + 'refuses a receipt with no workspace (%j)', + (result) => { + expect(readAgentLaunchCreateOutcome(result)).toBeNull() + } + ) + + it('carries the launch warning, so a workspace that is incomplete says why', () => { + // A launch can seat the workspace and still fail to finish it — an unspawned pty, untracked + // files left behind. Dropping the reason is what leaves the phone on a workspace that is + // quietly wrong. The host reports it at the top level, the same place worktree.create does. + expect( + readAgentLaunchCreateOutcome({ + worktreeId: 'wt-1', + outcome: { kind: 'structured', sessionId: 's-1', handle: 'agent-session:s-1' }, + warning: 'No pty available' + }) + ).toEqual({ worktreeId: 'wt-1', warning: 'No pty available' }) + }) + + it.each([ + { label: 'blank', warning: ' ' }, + { label: 'absent', warning: undefined }, + { label: 'non-string', warning: 7 } + ])('reports no warning when it is $label', ({ warning }) => { + expect( + readAgentLaunchCreateOutcome({ + worktreeId: 'wt-1', + outcome: { kind: 'terminal', handle: 'term-1' }, + warning + }) + ).toEqual({ worktreeId: 'wt-1' }) + }) + + it('reads a warning an older host nested on the outcome', () => { + // A host from before the warning moved to the top level nests it on the terminal outcome, and + // advertises the same `agent.launch.v1`, so this route really is taken against one. Its warning + // is legitimate, not a stale shape to defend against: dropping it loses the incomplete-create + // notice the `worktree.create` path already delivered, which is a regression rather than a + // contract cleanup. + expect( + readAgentLaunchCreateOutcome({ + worktreeId: 'wt-1', + outcome: { kind: 'terminal', handle: 'term-1', warning: ' startup terminal failed ' } + }) + ).toEqual({ worktreeId: 'wt-1', warning: 'startup terminal failed' }) + }) + + it('prefers the top-level warning over a nested one', () => { + // A current host writes only the top level — `AgentLaunchOutcome` has no `warning` on either + // arm, so it cannot nest one — meaning this case cannot arise from one. Pinned anyway so the + // migration fallback can never shadow the fresher value. + expect( + readAgentLaunchCreateOutcome({ + worktreeId: 'wt-1', + outcome: { kind: 'terminal', handle: 'term-1', warning: 'nested' }, + warning: 'top level' + }) + ).toEqual({ worktreeId: 'wt-1', warning: 'top level' }) + }) +}) + +describe('isAgentLaunchUnsupportedRefusal', () => { + it.each([ + { code: 'method_not_found', message: 'Unknown method: agent.launch' }, + { code: 'forbidden', message: "Method 'agent.launch' is not available to mobile clients" }, + { code: 'internal_error', message: 'agent_launch_unsupported' } + ])('treats $code as a reason to fall back to worktree.create', (error) => { + expect(isAgentLaunchUnsupportedRefusal(error)).toBe(true) + }) + + it.each([ + { code: 'x', message: 'Branch "otter" already exists locally.' }, + { code: 'x', message: 'SSH connection is not available' }, + {} + ])('leaves a create failure alone (%j)', (error) => { + expect(isAgentLaunchUnsupportedRefusal(error)).toBe(false) + }) +}) diff --git a/mobile/src/tasks/agent-launch-worktree-create.ts b/mobile/src/tasks/agent-launch-worktree-create.ts new file mode 100644 index 00000000000..a5fc5ef5d5a --- /dev/null +++ b/mobile/src/tasks/agent-launch-worktree-create.ts @@ -0,0 +1,95 @@ +/** + * Issuing a mobile workspace create through `agent.launch` rather than `worktree.create`. + * + * `worktree.create` + `startupAgent` means "create the worktree agent-first": its startup terminal + * IS the agent, so the structured branch below it is unreachable. That is why picking an agent on + * the mobile create sheet always produced a PTY while the in-workspace "+" button produced a chat. + * `agent.launch` carries the same create payload but lets the host settle the surface, so both + * mobile entry points route the same way. + * + * The request is built from the caller's existing `worktree.create` params so the fallback path + * stays byte-identical; the reserved agent fields are stripped here with the shared helper the + * host applies anyway. + */ + +import { + withoutReservedAgentCreateFields, + type AgentLaunchResult +} from '../../../src/shared/agent-launch-intent' +import type { TuiAgent } from '../../../src/shared/tui-agent' +import type { RpcSendParams } from '../transport/rpc-params-contract' +import type { WorkspaceCreateParams } from './workspace-create-params' + +export type WorktreeCreateAgentLaunch = { + agent: TuiAgent + /** Resolved before the first create: an older host has no `agent.launch` at all. */ + supported: boolean | Promise +} + +/** `worktreeId` is tied to the shared contract so a change to it fails this reader's typecheck + * rather than silently passing a differently-typed field through. */ +export type AgentLaunchCreateOutcome = { + worktreeId: AgentLaunchResult['worktreeId'] + warning?: string +} + +export function agentLaunchCreateParams( + agent: TuiAgent, + create: WorkspaceCreateParams +): RpcSendParams<'agent.launch'> { + return { + agent, + target: { kind: 'create-worktree', create: withoutReservedAgentCreateFields(create) } + } +} + +/** + * Reads the launch receipt. + * + * Deliberately mode-blind: whichever surface the host built, it published and activated that tab + * before answering, so the create flow navigates to the workspace and the host's own active-tab + * marking decides what opens. That is why nothing here branches on `outcome.kind` to pick a + * destination — a create-time guess would just race the snapshot that already knows. + */ +export function readAgentLaunchCreateOutcome(result: unknown): AgentLaunchCreateOutcome | null { + if (!result || typeof result !== 'object' || !('worktreeId' in result)) { + return null + } + const worktreeId = result.worktreeId + if (typeof worktreeId !== 'string' || !worktreeId.trim()) { + return null + } + // A current host reports an incomplete create at the top level, the same place `worktree.create` + // puts it, so nothing here branches on which surface the host built to find it. A host that + // predates that move nests the same warning on the terminal outcome instead, and still advertises + // the one `agent.launch.v1` capability, so this route cannot tell the two apart up front — read + // both shapes for as long as such a host can be paired. Top level wins: it is the only place a + // current host writes, so the fallback cannot shadow a fresher value. + const warning = + readTrimmedWarning(result) || readTrimmedWarning('outcome' in result ? result.outcome : null) + return { worktreeId, ...(warning ? { warning } : {}) } +} + +function readTrimmedWarning(source: unknown): string { + if (!source || typeof source !== 'object' || !('warning' in source)) { + return '' + } + return typeof source.warning === 'string' ? source.warning.trim() : '' +} + +/** + * Whether the host rejected the method itself rather than the create. + * + * The `status.get` probe can be stale in one direction that matters: the host advertises + * `agent.launch.v1` but has not yet recorded this client's own capability list, and then refuses + * the call. Downgrading to `worktree.create` keeps that race from failing a create outright. + */ +export function isAgentLaunchUnsupportedRefusal(error: { + code?: string + message?: string +}): boolean { + if (error.code === 'method_not_found' || error.code === 'forbidden') { + return true + } + return (error.message ?? '').includes('agent_launch_unsupported') +} diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts index da2e187a303..d2bd5e749fa 100644 --- a/mobile/src/tasks/blank-workspace-create.test.ts +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -18,13 +18,23 @@ function fakeClient(script: (method: string, call: number) => unknown, calls: Ca return { id: '1', ok: false, - error: { code: 'x', message: result.message }, + // `name` stands in for the wire error code, which is what tells a refused method apart + // from a refused create. + error: { code: result.name === 'Error' ? 'x' : result.name, message: result.message }, _meta: { runtimeId: 'r' } } } return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } - } - } as unknown as RpcClient + }, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } } describe('createBlankWorkspace', () => { @@ -40,7 +50,9 @@ describe('createBlankWorkspace', () => { comment: undefined, setupDecision: 'inherit', nameWasGenerated: false, - worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + // Default the existing cases to an old host so they keep pinning the legacy create. + agentLaunchSupported: false }) expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' }) @@ -77,7 +89,9 @@ describe('createBlankWorkspace', () => { comment: undefined, setupDecision: 'inherit', nameWasGenerated: true, - worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + // Default the existing cases to an old host so they keep pinning the legacy create. + agentLaunchSupported: false }) expect(calls[0]?.params).toMatchObject({ nameWasGenerated: true }) @@ -97,7 +111,9 @@ describe('createBlankWorkspace', () => { comment: 'spike', setupDecision: 'run', nameWasGenerated: false, - worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + // Default the existing cases to an old host so they keep pinning the legacy create. + agentLaunchSupported: false }) const params = calls[0]?.params as Record @@ -112,6 +128,146 @@ describe('createBlankWorkspace', () => { expect('startupCommand' in params).toBe(false) }) + it('routes a picked agent through agent.launch on a host that advertises it', async () => { + // The bug: `worktree.create` + `startupAgent` creates the worktree agent-first, so its startup + // terminal IS the agent and the user's structured-chat default can never apply. + const calls: Call[] = [] + const client = fakeClient( + () => ({ worktreeId: 'wt-9', outcome: { kind: 'structured' } }), + calls + ) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-2', + baseName: 'manatee', + createdWithAgentId: 'claude', + comment: 'spike', + setupDecision: 'run', + nameWasGenerated: false, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + agentLaunchSupported: true + }) + + expect(result).toEqual({ worktreeId: 'wt-9', name: 'manatee' }) + expect(calls).toHaveLength(1) + expect(calls[0]?.method).toBe('agent.launch') + // Everything the create needs survives the move; only the agent fields the launch owns go. + expect(calls[0]?.params).toMatchObject({ + agent: 'claude', + target: { + kind: 'create-worktree', + create: { + repo: 'id:repo-2', + name: 'manatee', + setupDecision: 'run', + displayName: 'manatee', + displayNameKind: 'user', + comment: 'spike', + clientMutationId: expect.any(String) + } + } + }) + expect(calls[0]?.params).not.toHaveProperty(['target', 'create', 'startupAgent']) + }) + + it('keeps the agent-first create on a host that does not advertise agent.launch', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-10' } }), calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-2', + baseName: 'manatee', + createdWithAgentId: 'claude', + comment: undefined, + setupDecision: 'inherit', + nameWasGenerated: false, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + agentLaunchSupported: false + }) + + expect(result).toEqual({ worktreeId: 'wt-10', name: 'manatee' }) + expect(calls[0]?.method).toBe('worktree.create') + expect(calls[0]?.params).toMatchObject({ startupAgent: 'claude', createdWithAgent: 'claude' }) + }) + + it('never launches for a blank choice, however capable the host is', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-11' } }), calls) + + await createBlankWorkspace({ + client, + repoId: 'repo-2', + baseName: 'manatee', + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit', + nameWasGenerated: false, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + agentLaunchSupported: true + }) + + expect(calls[0]?.method).toBe('worktree.create') + expect(calls[0]?.params).not.toHaveProperty('startupAgent') + }) + + it('keeps the name-collision retry when the create goes through agent.launch', async () => { + const calls: Call[] = [] + const client = fakeClient((_method, call) => { + if (call === 1) { + return new Error('Branch "octopus" already exists locally. Pick a different branch name.') + } + return { worktreeId: 'wt-12', outcome: { kind: 'terminal', handle: 't-1' } } + }, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + createdWithAgentId: 'codex', + comment: undefined, + setupDecision: 'inherit', + nameWasGenerated: false, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + agentLaunchSupported: true + }) + + expect(result).toEqual({ worktreeId: 'wt-12', name: 'octopus-2' }) + expect(calls.map((call) => call.method)).toEqual(['agent.launch', 'agent.launch']) + expect(calls[1]?.params).toMatchObject({ target: { create: { name: 'octopus-2' } } }) + }) + + it('downgrades to worktree.create when the host refuses the method itself', async () => { + // The status.get probe can win the race against this client's own capability advertisement; + // a refused method must not fail the create outright. + const calls: Call[] = [] + const client = fakeClient((method) => { + if (method === 'agent.launch') { + const refusal = new Error("Method 'agent.launch' is not available to mobile clients") + refusal.name = 'forbidden' + return refusal + } + return { worktree: { id: 'wt-13' } } + }, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + createdWithAgentId: 'codex', + comment: undefined, + setupDecision: 'inherit', + nameWasGenerated: false, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + agentLaunchSupported: true + }) + + expect(result).toEqual({ worktreeId: 'wt-13', name: 'octopus' }) + expect(calls.map((call) => call.method)).toEqual(['agent.launch', 'worktree.create']) + expect(calls[1]?.params).toMatchObject({ startupAgent: 'codex' }) + }) + it('retries with a numeric suffix on a branch-collision error', async () => { const calls: Call[] = [] const client = fakeClient((_method, call) => { @@ -129,7 +285,9 @@ describe('createBlankWorkspace', () => { comment: undefined, setupDecision: 'inherit', nameWasGenerated: false, - worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + // Default the existing cases to an old host so they keep pinning the legacy create. + agentLaunchSupported: false }) expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' }) @@ -155,7 +313,9 @@ describe('createBlankWorkspace', () => { comment: undefined, setupDecision: 'inherit', nameWasGenerated: false, - worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + // Default the existing cases to an old host so they keep pinning the legacy create. + agentLaunchSupported: false }) expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' }) @@ -174,10 +334,48 @@ describe('createBlankWorkspace', () => { comment: undefined, setupDecision: 'skip', nameWasGenerated: false, - worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + // Default the existing cases to an old host so they keep pinning the legacy create. + agentLaunchSupported: false }) expect(result).toEqual({ error: 'SSH connection is not available' }) expect(calls).toHaveLength(1) }) + + it.each([ + { label: 'worktree.create', supported: false, agent: undefined, reply: { worktree: {} } }, + { + label: 'agent.launch', + supported: true, + agent: 'codex' as const, + reply: { outcome: { kind: 'structured', sessionId: 's-1' } } + } + ])( + 'fails without retrying when an accepted $label reply names no workspace', + async ({ supported, agent, reply }) => { + // The break branch. The host accepted the call but the reply carries no workspace, so there + // is nothing to navigate to and a retry cannot help — a malformed reply is not a name + // collision. Both routes reach it: worktree.create with no `worktree.id`, agent.launch with + // no `worktreeId`. Before the guard, the legacy route read `.worktree.displayName` straight + // off the reply and surfaced a TypeError instead of a message. + const calls: Call[] = [] + const client = fakeClient(() => reply, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + createdWithAgentId: agent, + comment: undefined, + setupDecision: 'inherit', + nameWasGenerated: false, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + agentLaunchSupported: supported + }) + + expect(result).toEqual({ error: 'Failed to create workspace' }) + expect(calls).toHaveLength(1) + } + ) }) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts index ea3c827c3b9..7e96d96b71c 100644 --- a/mobile/src/tasks/blank-workspace-create.ts +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -1,9 +1,10 @@ import type { TuiAgent } from '../../../src/shared/tui-agent' import type { RpcClient } from '../transport/rpc-client' import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' +import type { WorktreeCreateAgentLaunch } from './agent-launch-worktree-create' import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy' import { - agentLaunchCreateFields, + startupAgentCreateFields, type WorkspaceCreateParams, type WorkspaceCreateSetupDecision } from './workspace-create-params' @@ -22,12 +23,18 @@ export async function createBlankWorkspace(args: { * may the host retire it. */ nameWasGenerated: boolean worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe + /** Whether the host can settle the surface itself; false keeps the agent-first create. */ + agentLaunchSupported: boolean | Promise }): Promise { + const agentLaunch: WorktreeCreateAgentLaunch | undefined = args.createdWithAgentId + ? { agent: args.createdWithAgentId, supported: args.agentLaunchSupported } + : undefined return createWorktreeWithNameRetry({ client: args.client, baseName: args.baseName, nameWasGenerated: args.nameWasGenerated, worktreeCreateIdempotency: args.worktreeCreateIdempotency, + ...(agentLaunch ? { agentLaunch } : {}), buildParams: (name) => { const params: WorkspaceCreateParams = { repo: `id:${args.repoId}`, @@ -37,7 +44,7 @@ export async function createBlankWorkspace(args: { ? { displayNameKind: 'generated' as const } : { displayName: args.baseName, displayNameKind: 'user' as const }), ...(args.nameWasGenerated ? { nameWasGenerated: true } : {}), - ...agentLaunchCreateFields(args.createdWithAgentId) + ...startupAgentCreateFields(args.createdWithAgentId) } if (args.comment) { params.comment = args.comment diff --git a/mobile/src/tasks/mobile-workspace-create-operations.ts b/mobile/src/tasks/mobile-workspace-create-operations.ts index 186d04b1498..3da90f200ed 100644 --- a/mobile/src/tasks/mobile-workspace-create-operations.ts +++ b/mobile/src/tasks/mobile-workspace-create-operations.ts @@ -20,6 +20,21 @@ export const worktreeCreateRun = bindDeferredRpcOperation( }) ) +/** + * agent.launch carrying a create payload: the host settles whether the agent lands in a structured + * session or a terminal. Same reply discipline as worktreeCreateRun, and for the same reason — the + * two share one clientMutationId, so the retry loop must see an unlost reply either way. + */ +export const agentLaunchRun = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'agent.launch', + method: 'agent.launch', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('agent-launch-receipt') + }) +) + /** * The start point for a workspace created from a linked pull request. Refusal throws the host's * message; an accepted reply can still carry a soft `{ error }` the caller raises itself. diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts index a86a5b463c1..24506b909e6 100644 --- a/mobile/src/tasks/source-workspace-create.test.ts +++ b/mobile/src/tasks/source-workspace-create.test.ts @@ -35,7 +35,9 @@ const baseArgs = { agent, workspaceName: undefined, note: undefined, - worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + // Existing cases keep pinning the legacy create; the launch cases opt in explicitly. + agentLaunchSupported: false } describe('createWorkspaceFromComposerSource', () => { @@ -300,4 +302,148 @@ describe('createWorkspaceFromComposerSource', () => { }) expect('startupCommand' in calls[0]!.params).toBe(false) }) + it('routes a branch selection with an agent through agent.launch', async () => { + const calls: Call[] = [] + const client = fakeClient( + () => ({ worktreeId: 'wt-branch-launch', outcome: { kind: 'structured' } }), + calls + ) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'main', + refName: 'main', + localBranchName: 'topic', + reuse: false, + branchNameOverride: 'topic' + } + + const result = await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + agent: { choice: 'claude' }, + agentLaunchSupported: true + }) + + expect(result).toEqual({ worktreeId: 'wt-branch-launch', name: 'topic' }) + expect(calls[0]!.method).toBe('agent.launch') + expect(calls[0]?.params).toMatchObject({ + agent: 'claude', + target: { create: { baseBranch: 'main', name: 'topic' } } + }) + expect(calls[0]?.params).not.toHaveProperty(['target', 'create', 'startupAgent']) + }) + + it('routes a reused branch through agent.launch without spending the retry budget', async () => { + const calls: Call[] = [] + const client = fakeClient(() => new Error('Branch "topic" already exists locally.'), calls) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'main', + refName: 'origin/topic', + localBranchName: 'topic', + reuse: true + } + + await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + agent: { choice: 'codex' }, + agentLaunchSupported: true + }) + + expect(calls.map((call) => call.method)).toEqual(['agent.launch']) + }) + + it('routes a new-branch selection with an agent through agent.launch', async () => { + const calls: Call[] = [] + const client = fakeClient( + () => ({ worktreeId: 'wt-new-branch-launch', outcome: { kind: 'terminal', handle: 't' } }), + calls + ) + const selection: MobileComposerCreateSelection = { kind: 'new-branch', branchName: 'topic' } + + await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + agent: { choice: 'claude' }, + agentLaunchSupported: true + }) + + expect(calls[0]!.method).toBe('agent.launch') + expect(calls[0]?.params).toMatchObject({ + target: { create: { name: 'topic', branchNameOverride: 'topic' } } + }) + expect(calls[0]?.params).not.toHaveProperty(['target', 'create', 'startupAgent']) + }) + + it('keeps a work-item create on worktree.create so its unsent draft survives', async () => { + // Scope boundary: an agent-carrying work-item create pre-fills the issue/PR URL as an unsent + // `startupDraft`. A structured session has nowhere to hold one, so routing it would submit the + // URL as the first turn. Stay on the terminal until drafts land. + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-draft' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'issue', + number: 7, + title: 'Bug', + url: 'https://github.test/acme/app/issues/7', + repoId: 'repo-9' + } + } + + await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + agent: { choice: 'claude' }, + agentLaunchSupported: true + }) + + expect(calls.map((call) => call.method)).toEqual(['worktree.create']) + // The host picks the agent for a work item (desktop parity) and drafts the URL into it, so the + // payload carries `createdWithAgent` + `startupDraft` rather than a `startupAgent`. + expect(calls[0]!.params).toMatchObject({ + createdWithAgent: 'claude', + startupDraft: 'https://github.test/acme/app/issues/7' + }) + }) + + it('keeps the agent-first create for a branch selection on an old host', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-old-host' } }), calls) + const selection: MobileComposerCreateSelection = { kind: 'new-branch', branchName: 'topic' } + + await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + agent: { choice: 'claude' }, + agentLaunchSupported: false + }) + + expect(calls[0]!.method).toBe('worktree.create') + expect(calls[0]!.params).toMatchObject({ startupAgent: 'claude', createdWithAgent: 'claude' }) + }) + + it('never launches for a blank choice on a capable host', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-blank-choice' } }), calls) + const selection: MobileComposerCreateSelection = { kind: 'new-branch', branchName: 'topic' } + + await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + agentLaunchSupported: true + }) + + expect(calls[0]!.method).toBe('worktree.create') + expect('startupAgent' in calls[0]!.params).toBe(false) + }) }) diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts index 30dd16f89fb..81cc7ab3a6e 100644 --- a/mobile/src/tasks/source-workspace-create.ts +++ b/mobile/src/tasks/source-workspace-create.ts @@ -1,3 +1,4 @@ +import type { TuiAgent } from '../../../src/shared/tui-agent' import type { RpcClient } from '../transport/rpc-client' import { resolveComposerMrBase, resolveComposerPrBase } from './composer-source-base-resolve' import type { @@ -7,13 +8,14 @@ import type { import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' import type { WorkspaceAgentChoice } from './workspace-agent-selection' import { - agentLaunchCreateFields, + startupAgentCreateFields, buildTaskWorkspaceCreateParams, type WorkspaceCreateParams, type WorkspaceCreateSetupDecision, type WorkspaceCreateTaskItem } from './workspace-create-params' import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' +import type { WorktreeCreateAgentLaunch } from './agent-launch-worktree-create' import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy' // The agent bundle the modal resolved: `choice` drives launch resolution — the @@ -32,6 +34,8 @@ export type CreateWorkspaceFromComposerArgs = { nameIsAutoManaged?: boolean note: string | undefined worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe + /** Whether the host can settle the surface itself; false keeps the agent-first create. */ + agentLaunchSupported: boolean | Promise } export async function createWorkspaceFromComposerSource( @@ -46,6 +50,13 @@ export async function createWorkspaceFromComposerSource( return createWorkItemWorkspace({ ...args, selection: args.selection }) } +function resolveComposerAgentLaunch( + agentId: TuiAgent | undefined, + supported: boolean | Promise +): WorktreeCreateAgentLaunch | undefined { + return agentId ? { agent: agentId, supported } : undefined +} + function toTaskItem(item: MobileLinkedWorkItem, targetRepoId: string): WorkspaceCreateTaskItem { if (item.provider === 'github') { return { @@ -136,6 +147,9 @@ async function createWorkItemWorkspace(args: { // buildTaskWorkspaceCreateParams computes the name; reuse it as the retry base // so collisions still append -2, -3, ... like the blank path does. const baseName = String(params.name) + // Deliberately NOT routed through `agent.launch`: an agent-carrying work-item create pre-fills + // the issue/PR URL as an unsent `startupDraft`, and a structured session has nowhere to put one + // — routing it would submit the URL as the first turn. Keep the terminal until drafts land. return createWorktreeWithNameRetry({ client, baseName, @@ -154,6 +168,7 @@ async function createBranchWorkspace(args: { nameIsAutoManaged?: boolean note: string | undefined worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe + agentLaunchSupported: boolean | Promise }): Promise { const { client, @@ -166,10 +181,11 @@ async function createBranchWorkspace(args: { note } = args const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice + const agentLaunch = resolveComposerAgentLaunch(createdWithAgentId, args.agentLaunchSupported) const comment = note?.trim() const manualDisplayName = nameIsAutoManaged === true ? undefined : workspaceName?.trim() const applyCommon = (params: WorkspaceCreateParams): WorkspaceCreateParams => { - Object.assign(params, agentLaunchCreateFields(createdWithAgentId)) + Object.assign(params, startupAgentCreateFields(createdWithAgentId)) if (comment) { params.comment = comment } @@ -188,6 +204,7 @@ async function createBranchWorkspace(args: { client, baseName, worktreeCreateIdempotency: args.worktreeCreateIdempotency, + ...(agentLaunch ? { agentLaunch } : {}), maxAttempts: 1, buildParams: (name) => applyCommon({ @@ -213,6 +230,7 @@ async function createBranchWorkspace(args: { client, baseName, worktreeCreateIdempotency: args.worktreeCreateIdempotency, + ...(agentLaunch ? { agentLaunch } : {}), buildParams: (candidate) => { const params: WorkspaceCreateParams = { repo: `id:${targetRepoId}`, @@ -241,6 +259,7 @@ async function createNewBranchWorkspace(args: { nameIsAutoManaged?: boolean note: string | undefined worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe + agentLaunchSupported: boolean | Promise }): Promise { const { client, @@ -253,6 +272,7 @@ async function createNewBranchWorkspace(args: { note } = args const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice + const agentLaunch = resolveComposerAgentLaunch(createdWithAgentId, args.agentLaunchSupported) const manualDisplayName = nameIsAutoManaged === true ? undefined : workspaceName?.trim() const comment = note?.trim() // A brand-new branch off the repo's default base. The typed name is kept as the @@ -263,6 +283,7 @@ async function createNewBranchWorkspace(args: { client, baseName: selection.branchName, worktreeCreateIdempotency: args.worktreeCreateIdempotency, + ...(agentLaunch ? { agentLaunch } : {}), buildParams: (candidate) => { const params: WorkspaceCreateParams = { repo: `id:${targetRepoId}`, @@ -272,7 +293,7 @@ async function createNewBranchWorkspace(args: { ...(manualDisplayName ? { displayName: manualDisplayName, displayNameKind: 'user' as const } : {}), - ...agentLaunchCreateFields(createdWithAgentId) + ...startupAgentCreateFields(createdWithAgentId) } if (comment) { params.comment = comment diff --git a/mobile/src/tasks/workspace-create-params.test.ts b/mobile/src/tasks/workspace-create-params.test.ts index 36eed50d80c..dd3a82c7387 100644 --- a/mobile/src/tasks/workspace-create-params.test.ts +++ b/mobile/src/tasks/workspace-create-params.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from 'vitest' -import { agentLaunchCreateFields, buildTaskWorkspaceCreateParams } from './workspace-create-params' +import { startupAgentCreateFields, buildTaskWorkspaceCreateParams } from './workspace-create-params' -describe('agentLaunchCreateFields', () => { +describe('startupAgentCreateFields', () => { it('sends startupAgent + createdWithAgent so the host resolves launch args', () => { - expect(agentLaunchCreateFields('claude')).toEqual({ + expect(startupAgentCreateFields('claude')).toEqual({ startupAgent: 'claude', createdWithAgent: 'claude' }) }) it('launches no agent when none was picked', () => { - expect(agentLaunchCreateFields(undefined)).toEqual({}) + expect(startupAgentCreateFields(undefined)).toEqual({}) }) }) diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts index 5f5adbecf5f..6f439793879 100644 --- a/mobile/src/tasks/workspace-create-params.ts +++ b/mobile/src/tasks/workspace-create-params.ts @@ -60,12 +60,14 @@ export type WorkspaceCreateTaskItem = export type WorkspaceCreateParams = RpcSendParams<'worktree.create'> /** - * `worktree.create` fields for launching the picked agent in a fresh session. + * `worktree.create` fields that create the worktree agent-first, so its startup terminal is the + * agent. Send the agent id rather than a command so the host resolves launch args (permission + * flags) and host-shell quoting, matching the "+" new-tab and CLI paths. * - * Why: send the agent id so the host resolves launch args (permission flags) - * and host-shell quoting, matching the "+" new-tab and CLI paths. + * These stay on every create: when the host routes through `agent.launch` it strips them and picks + * the surface itself, and when it cannot, they are still what makes the agent start. */ -export function agentLaunchCreateFields(agentId: TuiAgent | undefined): { +export function startupAgentCreateFields(agentId: TuiAgent | undefined): { startupAgent?: TuiAgent createdWithAgent?: TuiAgent } { diff --git a/mobile/src/tasks/worktree-create-capability.test.ts b/mobile/src/tasks/worktree-create-capability.test.ts index cad108e29d7..fd2bbaa14c2 100644 --- a/mobile/src/tasks/worktree-create-capability.test.ts +++ b/mobile/src/tasks/worktree-create-capability.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import type { RpcClient } from '../transport/rpc-client' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability' @@ -55,6 +56,22 @@ describe('readNewWorktreeRuntimeCapabilities', () => { ).resolves.toEqual({ tasksSupported: true, worktreeCreateIdempotency: { dedupeTtlMs: 20_000 }, + agentLaunch: false, + hostPlatform: 'darwin' + }) + }) + + it('reads agent.launch support from the same status.get probe', async () => { + // Why: mobile must not send `agent.launch` to a host that never advertised it, and one probe + // has to answer that alongside create idempotency so a create cannot straddle two answers. + await expect( + readNewWorktreeRuntimeCapabilities( + statusClient([{ capabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] }]) + ) + ).resolves.toEqual({ + tasksSupported: false, + worktreeCreateIdempotency: false, + agentLaunch: true, hostPlatform: 'darwin' }) }) @@ -65,6 +82,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => { ).resolves.toEqual({ tasksSupported: false, worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS }, + agentLaunch: false, hostPlatform: 'darwin' }) }) @@ -82,6 +100,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => { ).resolves.toEqual({ tasksSupported: false, worktreeCreateIdempotency: { dedupeTtlMs: 0 }, + agentLaunch: false, hostPlatform: 'darwin' }) }) @@ -106,6 +125,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => { ).resolves.toEqual({ tasksSupported: false, worktreeCreateIdempotency: { dedupeTtlMs: 0 }, + agentLaunch: false, hostPlatform: 'darwin' }) } @@ -124,6 +144,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => { ).resolves.toEqual({ tasksSupported: false, worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }, + agentLaunch: false, hostPlatform: 'darwin' }) }) @@ -143,6 +164,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => { ).resolves.toEqual({ tasksSupported: false, worktreeCreateIdempotency: { dedupeTtlMs: 0 }, + agentLaunch: false, hostPlatform: 'darwin' }) } @@ -162,6 +184,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => { ).resolves.toEqual({ tasksSupported: false, worktreeCreateIdempotency: { dedupeTtlMs: 30_000 }, + agentLaunch: false, hostPlatform: 'darwin' }) }) @@ -170,6 +193,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => { await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({ tasksSupported: false, worktreeCreateIdempotency: false, + agentLaunch: false, hostPlatform: null }) }) diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index 63a67dda2e8..3b91052bcf5 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import type { RpcClient } from '../transport/rpc-client' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform' @@ -20,12 +21,16 @@ const STATUS_CUTOVER_MAX_RETRIES = 5 export type NewWorktreeRuntimeCapabilities = { tasksSupported: boolean worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false + /** Whether the host can route a create through `agent.launch`; an older one only knows + * `worktree.create` + `startupAgent`, which is always a terminal agent. */ + agentLaunch: boolean hostPlatform: NodeJS.Platform | null } const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = { tasksSupported: false, worktreeCreateIdempotency: false, + agentLaunch: false, hostPlatform: null } @@ -54,6 +59,7 @@ export async function readNewWorktreeRuntimeCapabilities( const advertisedIdempotency = result.worktreeCreateIdempotency return { tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY), + agentLaunch: capabilities.includes(AGENT_LAUNCH_RUNTIME_CAPABILITY), worktreeCreateIdempotency: supportsIdempotency ? advertisedIdempotency === undefined ? { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } @@ -82,6 +88,7 @@ export function useNewWorktreeRuntimeCapabilities( tasksSupported: boolean hostPlatform: NodeJS.Platform | null getWorktreeCreateCutoverSupport: () => Promise + getAgentLaunchSupport: () => Promise } { const [tasksSupported, setTasksSupported] = useState(false) const [hostPlatform, setHostPlatform] = useState(null) @@ -123,5 +130,9 @@ export function useNewWorktreeRuntimeCapabilities( () => getCapabilities().then((capabilities) => capabilities.worktreeCreateIdempotency), [getCapabilities] ) - return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } + const getAgentLaunchSupport = useCallback( + () => getCapabilities().then((capabilities) => capabilities.agentLaunch), + [getCapabilities] + ) + return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport, getAgentLaunchSupport } } diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index fae847367d0..955f6d63a1a 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -1,7 +1,8 @@ +import type { TuiAgent } from '../../../src/shared/tui-agent' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse } from '../transport/types' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' -import { worktreeCreateRun } from './mobile-workspace-create-operations' +import { agentLaunchRun, worktreeCreateRun } from './mobile-workspace-create-operations' import { waitForRpcClientReconnected } from '../transport/rpc-client-reconnect-wait' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { @@ -10,6 +11,12 @@ import { getGeneratedWorktreeCreateRetryCandidate, isRetryableWorktreeCreateConflict } from '../../../src/shared/new-workspace/worktree-create-retry-policy' +import { + agentLaunchCreateParams, + isAgentLaunchUnsupportedRefusal, + readAgentLaunchCreateOutcome, + type WorktreeCreateAgentLaunch +} from './agent-launch-worktree-create' import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' import type { WorkspaceCreateParams } from './workspace-create-params' import { @@ -53,6 +60,9 @@ export type CreateWorktreeWithNameRetryArgs = { nameWasGenerated?: boolean buildParams: (name: string) => WorkspaceCreateParams worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe + /** Set when an agent was picked and the host may route the surface. Absent (or an unsupporting + * host) leaves `buildParams`' own `startupAgent` to create the worktree agent-first. */ + agentLaunch?: WorktreeCreateAgentLaunch maxAttempts?: number // Injected in tests; production mints a fresh idempotency key per candidate. mintMutationId?: () => string @@ -70,6 +80,9 @@ export async function createWorktreeWithNameRetry( // Why: creating before status.get settles would silently disable safe replay // during the exact slow-network window this path is meant to recover from. const worktreeCreateIdempotency = await args.worktreeCreateIdempotency + // Why: the route must settle before the first create, so a name-collision retry cannot land on + // a different method than the attempt it replaces. + let launchAgent = await resolveAgentLaunchRoute(args.agentLaunch) const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId let lastError: string | null = null @@ -84,27 +97,31 @@ export async function createWorktreeWithNameRetry( const params = worktreeCreateIdempotency ? { ...candidateParams, clientMutationId: mintMutationId() } : candidateParams - const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency) + let response = await sendWorktreeCreateResilient( + client, + launchAgent, + params, + worktreeCreateIdempotency + ) + if (!response.ok && launchAgent && isAgentLaunchUnsupportedRefusal(response.error)) { + // The probe said the host knows `agent.launch` but it refused the call — most likely this + // client's capability list had not landed yet. Downgrade for good rather than fail a create. + launchAgent = null + response = await sendWorktreeCreateResilient(client, null, params, worktreeCreateIdempotency) + } // Why the raw refusal: the retry decision below is `isRetryableWorktreeCreateConflict` over the // host's message, and no acceptance policy carries a refusal message through without throwing. if (response.ok) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = worktreeCreateRun.interpret(response) as { - worktree: { id: string; displayName?: string } - warning?: string - } - const authoritativeName = result.worktree.displayName - // Why: a create can succeed with the startup terminal failing (pty exhaustion); dropping - // `warning` here is what lands the phone on an unexplained empty session. - const warning = typeof result.warning === 'string' ? result.warning.trim() : '' - return { - worktreeId: result.worktree.id, - name: - typeof authoritativeName === 'string' && authoritativeName.trim() - ? authoritativeName - : candidateName, - ...(warning ? { warning } : {}) + const created = readCreateResult(response, launchAgent !== null) + if (created) { + return { + worktreeId: created.worktreeId, + name: created.displayName?.trim() ? created.displayName : candidateName, + ...(created.warning ? { warning: created.warning } : {}) + } } + lastError = 'Failed to create workspace' + break } lastError = response.error.message if (!isRetryableWorktreeCreateConflict(lastError ?? '')) { @@ -114,13 +131,54 @@ export async function createWorktreeWithNameRetry( return { error: lastError ?? 'Failed to create workspace' } } -// Sends worktree.create, re-issuing whenever the request went delivery-ambiguous — +async function resolveAgentLaunchRoute( + launch: WorktreeCreateAgentLaunch | undefined +): Promise { + if (!launch) { + return null + } + return (await launch.supported) ? launch.agent : null +} + +// A launch receipt carries no display name, so the candidate stands in; the session route +// re-resolves the authoritative one from the host either way. Both routes can report a warning: +// a create that seated the workspace but could not start the agent surface. +function readCreateResult( + response: RpcResponse, + launched: boolean +): { worktreeId: string; displayName?: string; warning?: string } | null { + if (launched) { + return readAgentLaunchCreateOutcome(agentLaunchRun.interpret(response)) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const created = worktreeCreateRun.interpret(response) as { + worktree?: { id?: unknown; displayName?: unknown } + warning?: unknown + } | null + const worktreeId = created?.worktree?.id + if (typeof worktreeId !== 'string' || !worktreeId) { + return null + } + const displayName = created?.worktree?.displayName + // Why: a create can succeed with the startup terminal failing (pty exhaustion); dropping + // `warning` here is what lands the phone on an unexplained empty session. + const warning = typeof created?.warning === 'string' ? created.warning.trim() : '' + return { + worktreeId, + ...(typeof displayName === 'string' ? { displayName } : {}), + ...(warning ? { warning } : {}) + } +} + +// Sends the create, re-issuing whenever the request went delivery-ambiguous — // the frame reached the wire but no response came back, so the host may already have -// built the worktree. The shared clientMutationId in `params` keeps the retry -// idempotent host-side. A definite failure (never sent, or a server error response) -// is returned to the caller untouched. +// built the worktree. The shared clientMutationId keeps the retry idempotent host-side — +// `agent.launch` carries it in the same create payload, so a replayed launch reconciles onto the +// first worktree and can at worst add a second surface inside it, never a second workspace. +// A definite failure (never sent, or a server error response) is returned to the caller untouched. async function sendWorktreeCreateResilient( client: RpcClient, + launchAgent: TuiAgent | null, params: WorkspaceCreateParams, worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false ): Promise { @@ -132,9 +190,13 @@ async function sendWorktreeCreateResilient( try { // `request` is the transport promise itself, so a delivery-unknown rejection reaches the // catch below as the object the transport marked — the WeakSet cannot see through a wrapper. - return await worktreeCreateRun.request(client, params, { - timeoutMs: WORKTREE_CREATE_TIMEOUT_MS - }) + return await (launchAgent + ? agentLaunchRun.request(client, agentLaunchCreateParams(launchAgent, params), { + timeoutMs: WORKTREE_CREATE_TIMEOUT_MS + }) + : worktreeCreateRun.request(client, params, { + timeoutMs: WORKTREE_CREATE_TIMEOUT_MS + })) } catch (error) { if (!worktreeCreateIdempotency) { throw error diff --git a/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts b/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts index cd39ca9a04b..891f134ec84 100644 --- a/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts +++ b/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts @@ -97,13 +97,16 @@ export function workspaceSettingsMounts( runSetup: false, trustedOrcaHooks: {}, getWorktreeCreateCutoverSupport: async () => false, + // False for the same reason as the cutover probe: an old host is the baseline the + // recordings pin, so the create stays on worktree.create rather than agent.launch. + getAgentLaunchSupport: async () => false, transitionDrawer: (view: unknown) => context.effect('drawer', view), onCreated: (id: unknown, name: unknown) => context.effect('created', { id, name }), onClose: () => context.effect('close', null) }) let state: ReturnType const hook = hookMount(() => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the model is partial by construction, so the assertion is what lets it mount. It also silences the compiler: when the hook gained a required getAgentLaunchSupport, nothing failed here and the scenario threw mid-submit instead. Add the member to the model above when this hook grows one. state = useSubmit(model as unknown as Parameters[0]) }) return { diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts index bdd1330de1d..96db82ed05b 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, @@ -25,6 +26,12 @@ describe('mobile runtime client capabilities', () => { ) }) + it('advertises agent.launch so the host may answer a create with either surface', () => { + // Why: `supportsAgentLaunch` refuses the method outright unless the client claims it, so + // without this every mobile create with an agent stays a terminal no matter the user default. + expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES).toContain(AGENT_LAUNCH_RUNTIME_CAPABILITY) + }) + it('stays inside the bounds the host parses, which fail closed to no capabilities at all', () => { expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES.length).toBeLessThanOrEqual(HOST_CAPABILITY_LIMIT) for (const capability of MOBILE_RUNTIME_CLIENT_CAPABILITIES) { diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index 30b627a9ef3..03050c65f28 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -1,4 +1,5 @@ import { + AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -13,7 +14,11 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, // Opts into the typed turn record; without it the host sends the legacy status carrier. - AGENT_SESSION_TURN_ITEM_CAPABILITY + AGENT_SESSION_TURN_ITEM_CAPABILITY, + // Mobile renders either launch outcome — a structured chat or a terminal agent — so it may ask + // the host to pick. Without this the host refuses `agent.launch` and every mobile create with an + // agent stays a PTY. + AGENT_LAUNCH_RUNTIME_CAPABILITY ]) export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD = diff --git a/mobile/src/transport/unchecked-rpc-reader-inventory.ts b/mobile/src/transport/unchecked-rpc-reader-inventory.ts index 28d656a43cc..d289b27507f 100644 --- a/mobile/src/transport/unchecked-rpc-reader-inventory.ts +++ b/mobile/src/transport/unchecked-rpc-reader-inventory.ts @@ -81,7 +81,9 @@ export const UNCHECKED_RPC_READERS: readonly UncheckedRpcReaderEntry[] = [ { file: 'src/tasks/mobile-task-project-board-operations.ts', readers: 17 }, { file: 'src/tasks/mobile-task-runtime-operations.ts', readers: 7 }, { file: 'src/tasks/mobile-task-source-search-operations.ts', readers: 7 }, - { file: 'src/tasks/mobile-workspace-create-operations.ts', readers: 4 }, + // #19850 brought the fifth (`agent-launch-receipt`): mobile's create routes through agent.launch + // when the host advertises it, and that reply is re-typed exactly as the four beside it are. + { file: 'src/tasks/mobile-workspace-create-operations.ts', readers: 5 }, { file: 'src/tasks/mobile-workspace-source-operations.ts', readers: 7 }, // terminal { file: 'src/terminal/mobile-terminal-operations.ts', readers: 4 }, diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index c1f93ac7aef..9b36d91ac05 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -80,7 +80,12 @@ export type AgentLaunchWorkspaceFactory = { * wait-for-setup gate for free. A structured launch has no startup command to sequence and * must await that gate explicitly instead. */ startupAgent: TuiAgent | undefined - }): Promise<{ worktreeId: string; startupTerminalHandle: string | undefined }> + }): Promise<{ + worktreeId: string + startupTerminalHandle: string | undefined + /** Created, but incomplete — surfaced on the launch result rather than dropped. */ + warning?: string + }> } export type AgentLaunchExecution = { @@ -125,6 +130,7 @@ export async function executeAgentLaunch( outcome: { kind: 'terminal', handle: placed.startupTerminalHandle }, worktreeId: placed.worktreeId, receipt: preflight, + ...(placed.warning ? { warning: placed.warning } : {}), ...promptReceipt(intent) } } @@ -139,9 +145,9 @@ export async function executeAgentLaunch( ) execution.onStage?.('surface_create') - let outcome: AgentLaunchResult['outcome'] + let created: { outcome: AgentLaunchResult['outcome']; warning?: string } try { - outcome = await createSurface(execution, placed.worktreeId, settled) + created = await createSurface(execution, placed.worktreeId, settled) } catch (error) { // The structured create path distinguishes a definitive pre-commit refusal from an unknown // outcome. Only the former is safe to replace with a terminal in the same workspace; retrying @@ -154,22 +160,33 @@ export async function executeAgentLaunch( throw error } settled = downgradeAgentLaunchModeForStructuredRefusal(settled, vocabulary) - outcome = await execution.surfaces + created = await execution.surfaces .createTerminalAgent({ worktreeId: placed.worktreeId, agent: intent.agent, ...(intent.sessionOptions ? { options: intent.sessionOptions } : {}) }) .then((terminal) => ({ - kind: 'terminal' as const, - handle: terminal.handle, + outcome: { kind: 'terminal' as const, handle: terminal.handle }, ...(terminal.warning ? { warning: terminal.warning } : {}) })) } + // Both CAN be set, so neither may be dropped. The create warns precisely when it produced no + // startup terminal — `didSpawnStartup` stays false when that spawn throws — and that is the same + // condition which skips the early return above, so the launch goes on to build a second surface, + // and that one can warn too. The other path is an untracked-copy warning followed by a structured + // refusal downgrading to a terminal that warns. `??` kept the first and lost the second silently. + // + // KNOWN GAP, deliberately not fixed here: a create warning about a FAILED startup terminal is + // stale once the launch recovers by building a working one, so the user can be told the agent did + // not start while looking at it. Telling those apart needs `createManagedWorktree` to stop + // multiplexing "couldn't copy untracked files" and "startup terminal failed" into one string. + const warning = combineLaunchWarnings(placed.warning, created.warning) return { - outcome, + outcome: created.outcome, worktreeId: placed.worktreeId, receipt: settled, + ...(warning ? { warning } : {}), ...promptReceipt(intent) } } @@ -189,9 +206,14 @@ function downgradeAgentLaunchModeForStructuredRefusal( async function resolveWorkspace( execution: AgentLaunchExecution, preflight: AgentLaunchModeReceipt -): Promise<{ worktreeId: string; startupTerminalHandle: string | undefined }> { +): Promise<{ + worktreeId: string + startupTerminalHandle: string | undefined + warning?: string +}> { const { intent } = execution if (intent.target.kind === 'existing') { + // Nothing was created, so there is no create warning to carry. return { worktreeId: intent.target.worktree, startupTerminalHandle: undefined } } const workspaces = execution.workspaces @@ -211,7 +233,7 @@ async function createSurface( execution: AgentLaunchExecution, worktreeId: string, settled: AgentLaunchModeReceipt -): Promise { +): Promise<{ outcome: AgentLaunchResult['outcome']; warning?: string }> { const { intent, surfaces } = execution if (settled.mode === 'structured' && isStructuredProvider(intent.agent)) { const session = await surfaces.createStructuredSession({ @@ -219,7 +241,7 @@ async function createSurface( agent: intent.agent, ...(intent.sessionOptions ? { options: intent.sessionOptions } : {}) }) - return { kind: 'structured', sessionId: session.sessionId, handle: session.handle } + return { outcome: { kind: 'structured', sessionId: session.sessionId, handle: session.handle } } } const terminal = await surfaces.createTerminalAgent({ worktreeId, @@ -227,12 +249,28 @@ async function createSurface( ...(intent.sessionOptions ? { options: intent.sessionOptions } : {}) }) return { - kind: 'terminal', - handle: terminal.handle, + outcome: { kind: 'terminal', handle: terminal.handle }, ...(terminal.warning ? { warning: terminal.warning } : {}) } } +/** + * Two warnings, both true, neither droppable. + * + * Mirrors how the create combines its own failures — `appendFailure` in + * runtime-local-worktree-terminal-startup.ts, and the startup-terminal catch in + * runtime-remote-managed-worktree-create.ts — which append rather than replace. + */ +function combineLaunchWarnings( + create: string | undefined, + surface: string | undefined +): string | undefined { + if (!create || !surface) { + return create ?? surface + } + return `${create} Also ${surface[0].toLowerCase()}${surface.slice(1)}` +} + function isStructuredProvider(agent: TuiAgent): agent is 'claude' | 'codex' { return agent === 'claude' || agent === 'codex' } diff --git a/src/main/runtime/rpc/methods/agent-launch-schemas.ts b/src/main/runtime/rpc/methods/agent-launch-schemas.ts index f71be37da3a..995c3d836f1 100644 --- a/src/main/runtime/rpc/methods/agent-launch-schemas.ts +++ b/src/main/runtime/rpc/methods/agent-launch-schemas.ts @@ -1,51 +1,4 @@ -/** - * The wire shape of `agent.launch`, mirroring `AgentLaunchIntent`. - * - * A caller states WHERE the agent lands and WHAT it should say; it never names a mode. There is - * deliberately no `structured` / `terminal` field and no startup-agent field on the create - * payload — the host decides, and `withoutReservedAgentCreateFields` strips a stale one out of a - * payload a caller migrated over from `worktree.create`. - */ - -import { z } from 'zod' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { WorktreeCreate } from './worktree-create-schemas' - -const LaunchAgent = z - .unknown() - .superRefine((value, ctx) => { - if (!isTuiAgent(value)) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' }) - } - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the superRefine above rejects anything isTuiAgent refuses, so the transform only ever runs on a TuiAgent. - .transform((value): TuiAgent => value as TuiAgent) - -export const AgentLaunch = z.object({ - agent: LaunchAgent, - target: z.discriminatedUnion('kind', [ - z.object({ - kind: z.literal('existing'), - /** Any selector the runtime resolves, the same as every other worktree-addressed method. */ - worktree: z.string().min(1, 'Missing worktree selector') - }), - z.object({ - kind: z.literal('create-worktree'), - /** The `worktree.create` request verbatim, so a caller migrating to this method keeps its - * existing payload; the agent fields in it are stripped rather than honoured. */ - create: WorktreeCreate - }) - ]), - prompt: z - .object({ - text: z.string(), - delivery: z.enum(['submit', 'draft']) - }) - .optional(), - /** Only the seedable string options a structured create accepts; a terminal launch ignores them. */ - sessionOptions: z.record(z.string(), z.string()).optional(), - reuseTerminal: z.object({ handle: z.string().min(1, 'Missing terminal handle') }).optional() -}) - -export type AgentLaunchParams = z.infer +export { + AgentLaunch, + type AgentLaunchParams +} from '../../../../shared/rpc-contract/agent-launch-params' diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index edf8dade6c1..7d236377a2e 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -76,7 +76,10 @@ export function agentLaunchWorkspaceFactory( finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) return { worktreeId: result.worktree.id, - startupTerminalHandle: result.startupTerminal?.handle + startupTerminalHandle: result.startupTerminal?.handle, + // Carried, not dropped: `createManagedWorktree` reports a failed startup terminal or an + // uncopied working tree here, and it is the only place the host says so. + ...(result.warning ? { warning: result.warning } : {}) } } catch (error) { releaseAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index aaf99d79629..db807f3e7f8 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -12,10 +12,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RpcContext } from '../core' -const createStructuredSession = vi.fn(async (_args: Record) => ({ - ok: true as const, - value: { sessionId: 'sess-1' } -})) +/** The real `createStructuredAgentSessionForWorktree` answers ok-or-refusal. The stub used to + * declare only the ok arm, which made the refusal-downgrade path unmodellable. */ +type StructuredCreateReply = + | { ok: true; value: { sessionId: string } } + | { ok: false; refusal: { code: string; message: string } } + +const createStructuredSession = vi.fn( + async (_args: Record): Promise => ({ + ok: true, + value: { sessionId: 'sess-1' } + }) +) vi.mock('./structured-agent-session-create', () => ({ createStructuredAgentSessionForWorktree: (args: Record) => @@ -40,6 +48,10 @@ function runtimeStub( state: 'running' | 'skipped' | 'not_configured' | 'spawn_failed' terminalHandle?: string } + /** What `createManagedWorktree` reports when the workspace exists but is incomplete. */ + createWarning?: string + /** What `createTerminal` reports when the surface itself came up degraded. */ + terminalWarning?: string } = {} ) { const worktreeCreateResults = new Map>() @@ -73,9 +85,13 @@ function runtimeStub( createManagedWorktree: vi.fn(async (args: Record) => ({ worktree: { id: 'wt-new' }, startupTerminal: args.startupAgent ? { handle: 'term_agent_first' } : undefined, - ...(options.setupReceipt ? { setupReceipt: options.setupReceipt } : {}) + ...(options.setupReceipt ? { setupReceipt: options.setupReceipt } : {}), + ...(options.createWarning ? { warning: options.createWarning } : {}) + })), + createTerminal: vi.fn(async () => ({ + handle: 'term_1', + ...(options.terminalWarning ? { warning: options.terminalWarning } : {}) })), - createTerminal: vi.fn(async () => ({ handle: 'term_1' })), showTerminal: vi.fn(async (handle: string) => ({ handle, worktreeId: 'wt-7' })), isTerminalRunningAgent: vi.fn(async () => true), showManagedTerminalWorkspace: vi.fn(async (selector: string) => ({ @@ -428,6 +444,67 @@ describe('the structured session factory', () => { }) }) +describe('a create that succeeded but is incomplete', () => { + // createManagedWorktree reports an unspawned startup terminal or an uncopied working tree as a + // top-level `warning`, and worktree.create hands it straight to mobile. This path narrowed the + // create down to {worktreeId, startupTerminalHandle} and dropped it — on BOTH arms, but the + // structured arm is the one that had no channel for a warning at all. + it('carries a create warning onto a structured launch', async () => { + const runtime = runtimeStub({ + createWarning: 'Could not copy untracked files into the new workspace.' + }) + + const result = await launch(CREATE_LAUNCH, runtime) + + expect(result.outcome.kind).toBe('structured') + expect(result.warning).toBe('Could not copy untracked files into the new workspace.') + }) + + it('carries a create warning onto an agent-first terminal launch', async () => { + // settings: {} leaves the structured preference off, so the launch is agent-first and returns + // on the cached startup handle - the early path that also had to learn to carry a warning. + // Wording matters: the producer cannot emit "startup terminal failed" ALONGSIDE a handle — + // `orca-runtime-create-managed-worktree.ts:283` gates startupTerminal on the spawn having + // succeeded. An untracked-copy warning is the one that genuinely co-occurs with a handle. + const runtime = runtimeStub({ + settings: {}, + createWarning: 'Could not copy untracked files into the new workspace.' + }) + + const result = await launch(CREATE_LAUNCH, runtime) + + expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_agent_first' }) + expect(result.warning).toBe('Could not copy untracked files into the new workspace.') + }) + + it('combines a create warning with a surface warning instead of dropping one', async () => { + // Both are reachable together: the create warns about the untracked copy, the structured + // create is then definitively refused, and the terminal it downgrades to warns as well. + // `??` kept the first and lost the second with nothing saying so. + const runtime = runtimeStub({ + createWarning: 'Could not copy untracked files into the new workspace.', + terminalWarning: 'No pty was available for the agent.' + }) + createStructuredSession.mockResolvedValueOnce({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported', message: 'no structured host' } + }) + + const result = await launch(CREATE_LAUNCH, runtime) + + expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' }) + expect(result.warning).toBe( + 'Could not copy untracked files into the new workspace. Also no pty was available for the agent.' + ) + }) + + it('reports no warning when the create had none', async () => { + const runtime = runtimeStub() + const result = await launch(CREATE_LAUNCH, runtime) + expect(result.warning).toBeUndefined() + }) +}) + describe('the terminal factory', () => { it('starts the agent through the runtime launcher when the host refuses a session', async () => { const runtime = runtimeStub({ createSupport: { supported: false, reason: 'wsl' } }) diff --git a/src/main/runtime/rpc/rpc-params-type-parity.ts b/src/main/runtime/rpc/rpc-params-type-parity.ts index ae3a5fcc76f..263a7fe38cd 100644 --- a/src/main/runtime/rpc/rpc-params-type-parity.ts +++ b/src/main/runtime/rpc/rpc-params-type-parity.ts @@ -8,11 +8,7 @@ import type { ALL_RPC_METHODS } from './methods' type RegisteredMethod = (typeof ALL_RPC_METHODS)[number] // These schemas reach into src/main and have no shared catalog entry. -type UncataloguedMethod = - | 'agent.launch' - | 'emulator.install' - | 'orchestration.send' - | 'orchestration.taskUpdate' +type UncataloguedMethod = 'emulator.install' | 'orchestration.send' | 'orchestration.taskUpdate' type IsAny = 0 extends 1 & T ? true : false diff --git a/src/main/server/serve-stdout-boundary.ts b/src/main/server/serve-stdout-boundary.ts index b5010abc2d7..cdf03dab6e8 100644 --- a/src/main/server/serve-stdout-boundary.ts +++ b/src/main/server/serve-stdout-boundary.ts @@ -18,10 +18,7 @@ export function emitServeBrowserIdentityActionLine( let action: string | null = null if (status.identity.state === 'future') { action = 'browser identity data is from a newer version; update Orca' - } else if ( - status.identity.state === 'corrupt' || - status.identity.state === 'unreadable' - ) { + } else if (status.identity.state === 'corrupt' || status.identity.state === 'unreadable') { action = `browser identity data is ${status.identity.state}; reset it explicitly` } else if (status.migrationNotice?.degraded) { action = 'an old choice could not be inspected; choose Cleaned or Native' diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 7cc9e3b3413..be271b61785 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -252,9 +252,7 @@ export function BrowserPane({ /> ) : null} - {showUserAgent ? ( - - ) : null} + {showUserAgent ? : null} {showLinkRouting ? ( > -): Record { +export function withoutReservedAgentCreateFields>>( + create: Create +): Create { const stripped: Record = { ...create } for (const field of AGENT_LAUNCH_RESERVED_CREATE_FIELDS) { delete stripped[field] } - return stripped + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every reserved field is optional on a create payload, so dropping them leaves the caller's own shape. + return stripped as Create } diff --git a/src/shared/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts new file mode 100644 index 00000000000..3bf6ef7a6f8 --- /dev/null +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -0,0 +1,55 @@ +/** + * The wire shape of `agent.launch`, mirroring `AgentLaunchIntent`. + * + * A caller states WHERE the agent lands and WHAT it should say; it never names a mode. There is + * deliberately no `structured` / `terminal` field and no startup-agent field on the create + * payload — the host decides, and `withoutReservedAgentCreateFields` strips a stale one out of a + * payload a caller migrated over from `worktree.create`. + * + * Shared rather than main-side because every field resolves to a shared schema: a remote client + * that sends this method needs `RpcSendParams<'agent.launch'>` to exist, and a method missing from + * the catalog can only be sent through the raw request port. + */ + +import { z } from 'zod' +import { isTuiAgent } from '../tui-agent-config' +import type { TuiAgent } from '../tui-agent' +import { WorktreeCreate } from './worktree-create-params' + +const LaunchAgent = z + .unknown() + .superRefine((value, ctx) => { + if (!isTuiAgent(value)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' }) + } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the superRefine above rejects anything isTuiAgent refuses, so the transform only ever runs on a TuiAgent. + .transform((value): TuiAgent => value as TuiAgent) + +export const AgentLaunch = z.object({ + agent: LaunchAgent, + target: z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('existing'), + /** Any selector the runtime resolves, the same as every other worktree-addressed method. */ + worktree: z.string().min(1, 'Missing worktree selector') + }), + z.object({ + kind: z.literal('create-worktree'), + /** The `worktree.create` request verbatim, so a caller migrating to this method keeps its + * existing payload; the agent fields in it are stripped rather than honoured. */ + create: WorktreeCreate + }) + ]), + prompt: z + .object({ + text: z.string(), + delivery: z.enum(['submit', 'draft']) + }) + .optional(), + /** Only the seedable string options a structured create accepts; a terminal launch ignores them. */ + sessionOptions: z.record(z.string(), z.string()).optional(), + reuseTerminal: z.object({ handle: z.string().min(1, 'Missing terminal handle') }).optional() +}) + +export type AgentLaunchParams = z.infer diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 12d824ba781..e8ca11e2ca2 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -34,6 +34,7 @@ import { SelectCodexAccountForTargetParams } from './accounts-params' import { PrepareCodexForWslPaneParams } from './agent-hooks-params' +import { AgentLaunch } from './agent-launch-params' import { CreateAgentSessionParams, EnsureAgentSessionParams } from './agent-session-params' import { AiVaultListSessionsParams, @@ -555,6 +556,7 @@ export const RPC_PARAMS_BY_METHOD = { 'accounts.selectCodexForTarget': SelectCodexAccountForTargetParams, 'accounts.subscribe': null, 'accounts.unsubscribe': AccountsUnsubscribeParams, + 'agent.launch': AgentLaunch, 'agentHooks.prepareCodexForWslPane': PrepareCodexForWslPaneParams, 'agentSession.cancel': CancelParams, 'agentSession.close': OptionsParams, @@ -1164,7 +1166,6 @@ export const RPC_PARAMS_BY_METHOD = { // Why: these methods bind a schema the shared contract cannot hold because its value // graph reaches into src/main. Listing them keeps the gap visible instead of absent. export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [ - 'agent.launch', 'emulator.install', 'orchestration.send', 'orchestration.taskUpdate'