From 97aa5ff19b19231670ab7d06d070115640bb8f41 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:15:20 -0700 Subject: [PATCH] fix(mobile): open native chat when a new worktree launches a default agent (#19850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(agent-launch): make the launch-mode decision surface-neutral `decideWorkerStartMode` was the only shared answer to "structured chat session or terminal agent?", but it lived in an orchestration-named module and spoke orchestration's vocabulary, so the other launch surfaces could not call it. Move the decision to `main/agent-launch/agent-launch-mode` unchanged and leave `orchestration-worker-start-mode` as the adapter that supplies the noun. A worker is not a special kind of launch; it is the same launch with a dispatch attached. Naming the receipt's subject is the only thing orchestration actually contributed, so that is the only thing the adapter keeps: "worker" in both sentences, plus the `--terminal` wording, which reads as nonsense anywhere a `--terminal` flag does not exist. Both are pinned, because they are asserted. No behavior change. The receipts are byte-identical for every reachable case, proven by running the new pin against both implementations. Also pins the wording, which nothing was holding. The existing suites assert `toContain` fragments ('terminal agent', 'cannot create') and the CLI suite asserts a receipt handed to it by a mock rather than one this code produced; all six files stayed green against a deliberately corrupted vocabulary. A dispatch receipt is the only place a structured-to-terminal downgrade explains itself, so the whole sentence is the contract, not a fragment of it. * feat(agent-launch): add the launch intent and the one executor that runs it The sequencing around the launch decision was duplicated per surface, and the duplicate is where the bug lives. A new worktree was created agent-first, so its startup terminal WAS the agent and the structured branch below it could never be reached — every new-worktree launch was a PTY regardless of the user's default. Orchestration fixed that for itself in #19431; mobile and the CLI still have it. `executeAgentLaunch` inverts the order once, for everyone. When the preference is structured the worktree is created with NO startup agent, the executing host is then asked whether it can host a session for the workspace that now exists, and only then is a surface created. The host verdict cannot be hoisted above creation: `agentSession.createSupport` only answers for a workspace it can resolve, which is why the decision stays in two halves. Agent-first creation is deliberately preserved for PTY launches — it is what sequences the agent's startup command behind the setup runner, so wait-for-setup comes for free there. What actually differs per surface is only how a surface is built (an orchestration worker's session takes a dispatch hold and a mailbox a plain launch must not take), so that is injected as a factory rather than branched on. The intent also strips the reserved agent fields from a migrated create payload: a caller moving off `worktree.create` passes its existing params, and a stale `startupAgent` in there would re-create the very path this replaces. Tests assert order and arguments, not just the resulting mode. Reintroducing agent-first creation reddens 4 of 11. * feat(agent-launch): expose the launch executor as the agent.launch RPC Adds `agent.launch` — one host-side method that decides structured-vs-terminal and creates the surface — wired to the real runtime factories: `createManagedWorktree` for the workspace, forking on `startupAgent` exactly as the orchestration worker path does; `createStructuredAgentSessionForWorktree` for a chat session; and `createTerminal` for a PTY agent. Allowlisted for mobile, which is the surface the routing gap was reported on. `worktree.create` is untouched. Its `startupAgent` keeps meaning "spawn a PTY agent" verbatim, because it answers with `agentTerminalHandle` only on that path: a host that quietly routed it to a structured session would hand every older client a response with no handle and no error. All new behaviour sits behind `agent.launch.v1`, which the host now advertises and a remote client must negotiate, so a client that does not gets today's behaviour unchanged. * feat(mobile): route workspace creates through agent.launch Picking an agent on the mobile create sheet always produced a terminal, even when the user's default was native chat, because all three create paths put `startupAgent` on `worktree.create`. That means "create the worktree agent-first", so its startup terminal IS the agent and the structured branch below it is unreachable — while the same phone's in-workspace "+" button opened a chat. The blank, branch and new-branch creates now send the same payload through `agent.launch` and let the host settle the surface. `worktree.create` is untouched, and a host that does not advertise `agent.launch.v1` (read from the existing `status.get` probe) keeps today's path exactly. Work-item creates stay on `worktree.create`: they pre-fill the issue/PR URL as an unsent `startupDraft`, which a structured session cannot hold yet, so routing them would submit the URL as a first turn. * fix(agent-launch): drop the deleted draft-prompt blocker from the reason map main removed the draft-prompt blocker in #19681 (a structured session now holds an unsent draft), so the exhaustive Record no longer typechecks. * chore(agent-launch): carry a SAFETY rationale on the agent placement cast The type-assertion gate landed after this branch's base, so the new file's copy of the worker-start cast is now a changed-code finding. * chore(agent-launch): carry agent.launch through main's RPC typing and casting gates The typed-method contract, the generated params catalog and the `assertionStyle: never` casting scan all landed after this branch's base. - AGENT_LAUNCH_METHODS kept an `RpcMethod[]` annotation, which widened its method name to `string` and broke assignability; every sibling infers instead. - `agent.launch` binds a schema under src/main, so it joins the catalog's RPC_METHODS_WITHOUT_SHARED_PARAMS and the parity gate's hand-listed twin. - The now-typed methods make most test casts unnecessary; the few that remain carry the line-specific SAFETY rationale the casting gate requires. * test(mobile): supply the agent-launch fixture the create-submit recording needs The golden RPC recordings landed upstream while this branch was out, so they first met agent.launch here. Three things had to happen, and only one of them is a fixture bump. 1. workspace-settings-mounts.ts mounts useNewWorkspaceCreateSubmit against a fixture model that throws on any member it was not given. This PR added a required getAgentLaunchSupport, so the submit aborted with "Missing model fixture" before it ever issued the create, and three cleanup checkpoints vanished. That read like a product regression and was not one. Supplying the member restores the recording byte-for-byte; it is pinned false for the same reason the cutover probe is, so the baseline stays on worktree.create. 2. Editing that adapter moves adapterSha256 for the twelve settings goldens it mounts. Their recordings are unchanged - header only, by design: the digest is per-golden so editing a module fails exactly the goldens that mounted it. 3. Five goldens changed behaviourally, and both changes are this PR's: the capability probe now reports agentLaunch, and a create whose reply carries no worktree returns "Failed to create workspace" instead of throwing a TypeError off an unguarded result.worktree read. The launch route needs that guard, since a receipt can arrive without a worktreeId. * refactor(mobile): decode the launch receipt instead of asserting its shape The changed-code quality gate refuses type assertions, and the eight it flagged were worth removing rather than suppressing. The production one was the point. readAgentLaunchCreateOutcome asserted the RPC payload into Partial and then runtime-checked it anyway, so the assertion bought nothing and claimed a contract the host had not proven. It now narrows with `in` and validates each hop, which is the same nullability question readCreateResult already answers on the sibling path - a launch receipt can legitimately arrive without a worktreeId. AgentLaunchCreateOutcome ties worktreeId to the shared contract so a change there fails this reader's typecheck rather than passing a differently-typed field through. The test fakes claimed a whole RpcClient via `as unknown as RpcClient` while implementing one member. They now build a typed literal, matching the pattern in use-mobile-structured-agent-options.test.ts. The read sites cast params and then read one field; they now assert the payload with toMatchObject, which removes the cast and pins more of the shape than the cast did. Also pins the warning passthrough, which nothing covered: a terminal launch that seats the workspace but cannot start the pty reports why, and the absent, blank, non-string and structured-surface cases report nothing. Writing that test caught a real drop I had introduced in the reader. * ci(mobile): re-run Mobile Checks when a shared capability changes Mobile Checks is path-filtered to mobile/**, but mobile imports the negotiated capability names straight from src/shared/protocol-version.ts and records the whole capability read verbatim in its goldens. So a capability added desktop-side rewrites a mobile fixture while never triggering the suite that would catch it. That is what happened here: #19849 introduced agent.launch.v1 and Mobile Checks never ran on it. Verified at the run level rather than by check name - the window-free check-runs API on 3837ae8d51 returns 49 check-runs across six runs (PR Checks x2, PR test LoC x2, Track Community PRs, Review) and no Mobile Checks among them. The breakage surfaced only in this PR, which happens to touch mobile/**. The workflow already concedes this pattern for terminal-file-link-conformance.ts; protocol-version.ts has the stronger claim, since mobile records its output. Also corrects the mount adapter's SAFETY comment. It claimed the recorder supplies only the members the hook reads, which was false the moment the hook gained a required getAgentLaunchSupport - and the assertion it annotates is exactly what stopped the compiler from saying so. The twelve goldens are adapterSha256 churn from that comment: every body is byte-identical, which is the digest doing its job. * docs(agent-launch): stop the receipt-wording comment claiming a migration The decision was never moved out of orchestration-worker-start-mode; this PR adds a second copy beside it. Say so, and name the unenforced agreement. * docs(agent-launch): stop the executor comment claiming a migration that has not happened The header asserted two things the tree does not support: that every launch surface routes through the executor, and that the mode decision "already lived" in `agent-launch-mode`. `agent.launch` is the executor's only consumer, and `orchestration-worker-start-mode.ts` is byte-identical (blob 92dc5c644a89, 217 lines) at the merge base and all three stack heads, still used by workers.ts. Describe the two live copies and leave the cutover to later stack work. * fix(agent-launch): preserve setup and refusal fallbacks * refactor(mobile): parse the launch outcome into a named type at its boundary anti-slop/no-object-parameters flagged terminalLaunchWarning's `result: object`. The rule is pointing at a real seam rather than a style nit: the helper advertised a loose object and did the narrowing inside itself, so every caller handed it unparsed wire data and nothing downstream held a real type. Parsed at the boundary instead. parseTerminalLaunchOutcome takes `unknown` and returns TerminalLaunchOutcome | null, so the narrowing happens once, where the untrusted payload enters, and the consumer works with a named type. The type is taken from the shared contract rather than restated - a Pick over the terminal member of AgentLaunchOutcome - so a change to that union fails here instead of flowing through. `handle` is deliberately excluded: nothing reads it, and requiring it would drop the warning off a reply that omitted one, which is a behaviour change smuggled in under a typing change. No assertion and no config exemption: reintroducing `as Partial` would trade this finding for the defect removed earlier in this branch, and the rule is correct here. The rule arrived with the merge-forward (#20781, newer than this branch's merge-base), and anti-slop is not one of the changed-code gate's six scans - it runs only repo-wide - which is why a clean local gate did not predict it. Behaviour is unchanged across all five warning cases, and the positive case was re-ablated on the new parser: dropping the warning reddens exactly it, 1 failed | 18 passed, restored byte-identical to 19 passed. * fix(agent-launch): dedupe complete launch and cancel setup wait * fix(agent-launch): memoize the whole launch so a replay cannot mint a second session A replayed agent.launch could create a second structured session in the same worktree, with activate: true. dedupeWorktreeCreate wrapped only the worktree half, inside the workspace factory. On a replay the create was reused, and the executor then continued to createSurface and built another surface inside it. The terminal route hid this: its cached create carries a startup terminal handle, so the executor returns on early. A structured create has no handle by construction - that is the whole point of the structured fork - so it fell through every time. Mobile replays this method deliberately on a delivery-ambiguous response, up to five attempts, so the path is reachable by design rather than in theory. The handler now wraps the entire launch in the same dedupe, on the same (repo, clientMutationId) identity, exactly as worktree.create wraps its own body. A replay returns the original AgentLaunchResult instead of re-running createSurface, which makes the two routes replay-identical. The inner dedupe is removed rather than kept. Wrapping both levels on one key deadlocks: dedupeWorktreeCreate stores the in-flight promise before the inner call runs, so the inner call would be handed the outer's promise, which is waiting on it. The launch-level memo subsumes the worktree-level one. Failures are still dropped rather than cached, so an unknown outcome stays unknown instead of replaying as a fabricated success. The guard replays a STRUCTURED launch: the terminal route cannot reproduce this and a test there would pass either way. Ablated against the pre-fix files - 1 failed | 22 passed, "expected vi.fn() to be called 1 times, but got 2 times", which is the duplicate session - then restored to 23 passed. The stub's dedupe had to be made faithful for that to be observable; the shared one passes through so other tests can see raw calls. * Revert "fix(agent-launch): memoize the whole launch so a replay cannot mint a second session" This reverts commit 59bc5e9b0479e8c0278d5241ae70fbc9f1a349be. The same defect was already fixed upstream on this stack's base branch by 539e283c0f, which landed while this was being written. That change is broader (it also cancels the setup wait) and namespaces the dedupe key, so it supersedes this one. Reverting rather than hand-merging keeps a single implementation instead of a hybrid nobody chose. The behavioural guard from this commit is ported back on top of the upstream implementation separately: it asserts exactly one structured session survives a replay, where the upstream tests assert the dedupe wiring. * ci(mobile): close the round-1 signal gaps around agent.launch Three review findings, all narrow. Mobile Checks is path-filtered, and this branch made mobile's types depend on the shared RPC contract: rpc-params-contract.ts is a type-only re-export of the generated params catalog, and mobile/tsconfig.json includes **/*.ts. So a desktop-only edit under src/shared/rpc-contract/ could break mobile's typecheck with no mobile signal at all - the same blind spot the protocol-version.ts entry closed, one directory over. Added src/shared/rpc-contract/** to the paths filter. agent.launch had no cross-version trigger. Added the three prefixes a paired peer actually exchanges: the intent contract, the wire schema, and the RPC method. src/main/agent-launch/ is deliberately NOT listed - the executor shapes behaviour but is not itself wire, and AgentLaunchResult's shape is already covered by agent-launch-intent. Extending the cross-version SUITE to cover a negotiated handshake is separate work, not this. The break branch that answers an accepted-but-empty reply with "Failed to create workspace" had no unit coverage; the golden that used to discriminate it collapsed five partitions into one shared error when the null guard replaced the unchecked read. Covered on BOTH routes - worktree.create with no worktree.id and agent.launch with no worktreeId - since the branch serves both. Ablated by bypassing the guard: 2 failed | 11 passed, the two new cases returning a fabricated worktree instead of the error, restored to 13 passed. * fix(agent-launch): give a launch one place to say the workspace 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. The launch path narrowed that result down to {worktreeId, startupTerminalHandle} and dropped it, so every agent.launch create lost a warning the old method surfaces - on both arms. The channel was also asymmetric by accident rather than design: a terminal outcome could carry `warning`, a structured one had nowhere to put it, so the arm this PR exists to enable was the arm that could not report an incomplete create at all. Now there is exactly one place a launch warning lives: AgentLaunchResult.warning, at the top level. It is about the create as often as the surface, it applies to a structured session and a terminal alike, and a reader should not branch on outcome.kind to discover the workspace it just opened is missing something. The terminal arm's own `warning?` is removed rather than left beside it - two homes for one fact is how they drift. Every producer folds in: the create, the surface, and the refusal downgrade. Consumer census before removing it: one production reader (mobile's readAgentLaunchCreateOutcome) and no others - the renderer and mobile launch call sites never read it. The mobile reader now reads the top-level field, which also lets its outcome parser go away entirely. Guard ablated by restoring the pre-fix narrowing: 2 failed | 24 passed, both carriers reporting `expected undefined`, which is the dropped warning itself; restored to 26 passed. The third case asserts an absence and stays green under the mutation by construction - it pins shape, not the defect. * fix(agent-launch): combine both launch warnings instead of dropping one Round 2 found the comment here was false. A create warning and a surface warning CAN both be set, on two reachable paths: 1. The create warns precisely BECAUSE it produced no startup terminal - didSpawnStartup stays false when that spawn throws, and orca-runtime-create-managed-worktree.ts:283 gates startupTerminal on it - so the executor's early return is skipped and a second surface is built, which can warn too. 2. An untracked-copy warning, then a definitive structured refusal downgrading to a terminal that also warns. `??` kept the first and lost the second with nothing saying so. They are now combined the way 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. The comment is rewritten to say what is true, and records the gap NOT fixed here: a create warning about a failed startup terminal is stale once the launch recovers by building a working one, so a user can be told the agent did not start while looking at it. Distinguishing those needs createManagedWorktree to stop multiplexing two unrelated failures into one string. Guarded and ablated: restoring `??` reddens exactly the new test, with the surface clause missing from the received string; restored to 27 passed. The structured-create stub had to admit its real ok-or-refusal union for the downgrade path to be modellable at all - it previously declared only the ok arm. Also: mobile.yml gains src/shared/agent-launch-intent.ts. It is the sole holder of the agent.launch RESULT shape - the rpc-contract catalog holds params only - and mobile imports it as a value. CROSS_VERSION_WIRE_PREFIXES already treats it as wire-critical; without this, one gate does and the other cannot see it. And the agent-first warning test no longer pairs "startup terminal failed" with a returned handle, a combination the producer cannot emit. * fix(mobile): read a launch warning an older host nests on the outcome agent.launch moved `warning` from the terminal outcome to the top level of the result. That is the right shape - a reader should not branch on `outcome.kind` to learn the workspace it just opened is incomplete - but on the wire it is a REMOVAL, and mobile only read the new place. A host built before the move still advertises the same `agent.launch.v1` capability, so the capability probe cannot tell the two apart and mobile takes this route against one: protocol-version.ts:360 AGENT_LAUNCH_RUNTIME_CAPABILITY is in RUNTIME_CAPABILITIES, the host list orca-runtime-get-status.ts:64 publishes it via status.get; the filter drops only browser.screencast.v1 and three E2E-gated capabilities, never agent.launch agent-launch-executor.ts such a host writes warning INSIDE outcome The result was a regression rather than a contract cleanup: the worktree.create path this replaces returned the warning at the top level and mobile read it, so a create that seated the workspace but could not start the agent surface - pty exhaustion, untracked files not copied - stopped explaining itself on the phone. Read both shapes for as long as such a host can be paired. Top level wins, and cannot be shadowed: AgentLaunchOutcome has no `warning` on either arm, so a current host cannot nest one. The test that pinned the old behaviour is inverted here. Its comment was the actual defect - it framed a legitimate warning from an older peer as a stale shape to defend against, which is what made dropping it look deliberate. * chore(mobile): raise the unchecked-reader ceiling for the agent.launch receipt main landed `unchecked-rpc-reader-inventory.ts`, a ratchet on RpcOperation readers that re-type their reply instead of validating it. Its ceiling for mobile-workspace-create-operations.ts is 4, counted on a tree without this branch's `agentLaunchRun`, so the merge produced "listed 4, found 5". The inventory's own header prescribes this case: a merge is the one time a line goes up without a migration undoing itself, and the instruction is to raise it and name the PR that brought it. It describes main landing an operation the branch never saw; here it is the mirror - the branch holds one main had not seen - so the line is annotated with #19850 rather than left bare. Not converted to `rpcResultVariant(variant, schema)`, which would lower the line instead. That is a validation change rather than a migration, which is exactly what the file's own comment says these five readers deliberately are not; the agent.launch reply is already guarded at the consumer, where readAgentLaunchCreateOutcome returns null on a malformed payload and the create surfaces "Failed to create workspace". Writing a schema now would also target a reply shape #20999 is actively redefining. Ablated: with the line back at 4 the ratchet fails "listed 4, found 5"; at 5 it passes. --------- Co-authored-by: Merge Sim --- .github/workflows/mobile.yml | 14 ++ config/scripts/pr-code-change-scope.mjs | 3 + ....task-workspace-create-settings.get-1.json | 2 +- ...sk-workspace-create-worktree.create-1.json | 2 +- ...ettings.task-workspace-settings.get-1.json | 2 +- ...tings.workspace-submit-settings.get-1.json | 2 +- ...rktree.create-retry-worktree.create-1.json | 63 ++--- ...ree.runtime-capabilities-status.get-1.json | 124 +++++----- ...settings-task-workspace-create-linear.json | 2 +- ...-task-workspace-create-pr-start-point.json | 2 +- .../settings-task-workspace-fulfilled.json | 2 +- .../settings-task-workspace-refused.json | 2 +- ...ttings-task-workspace-transport-error.json | 2 +- .../settings-workspace-submit-fulfilled.json | 2 +- .../settings-workspace-submit-refused.json | 2 +- ...ings-workspace-submit-transport-error.json | 2 +- .../goldens/tw-capabilities-advertised.json | 56 ++--- .../tw-capabilities-cutover-retried.json | 42 ++-- .../tw-capabilities-legacy-idempotency.json | 56 ++--- mobile/src/components/NewWorktreeModal.tsx | 3 +- .../use-new-workspace-create-submit.ts | 7 +- mobile/src/session/active-session-tab.test.ts | 19 +- .../agent-launch-worktree-create.test.ts | 135 +++++++++++ .../src/tasks/agent-launch-worktree-create.ts | 95 ++++++++ .../src/tasks/blank-workspace-create.test.ts | 216 +++++++++++++++++- mobile/src/tasks/blank-workspace-create.ts | 11 +- .../mobile-workspace-create-operations.ts | 15 ++ .../src/tasks/source-workspace-create.test.ts | 148 +++++++++++- mobile/src/tasks/source-workspace-create.ts | 27 ++- .../src/tasks/workspace-create-params.test.ts | 8 +- mobile/src/tasks/workspace-create-params.ts | 10 +- .../tasks/worktree-create-capability.test.ts | 24 ++ .../src/tasks/worktree-create-capability.ts | 13 +- mobile/src/tasks/worktree-create-retry.ts | 112 +++++++-- .../adapters/workspace-settings-mounts.ts | 5 +- ...mobile-runtime-client-capabilities.test.ts | 7 + .../mobile-runtime-client-capabilities.ts | 7 +- .../unchecked-rpc-reader-inventory.ts | 4 +- .../agent-launch/agent-launch-executor.ts | 62 ++++- .../rpc/methods/agent-launch-schemas.ts | 55 +---- .../methods/agent-launch-worktree-creation.ts | 5 +- .../runtime/rpc/methods/agent-launch.test.ts | 89 +++++++- .../runtime/rpc/rpc-params-type-parity.ts | 6 +- src/main/server/serve-stdout-boundary.ts | 5 +- .../src/components/settings/BrowserPane.tsx | 4 +- src/shared/agent-launch-intent.ts | 23 +- .../rpc-contract/agent-launch-params.ts | 55 +++++ .../rpc-params-catalog.generated.ts | 3 +- 48 files changed, 1225 insertions(+), 330 deletions(-) create mode 100644 mobile/src/tasks/agent-launch-worktree-create.test.ts create mode 100644 mobile/src/tasks/agent-launch-worktree-create.ts create mode 100644 src/shared/rpc-contract/agent-launch-params.ts 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'