mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(mobile): open native chat when a new worktree launches a default agent (#19850)
* 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<AgentLaunchResult> 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 92dc5c644a, 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<AgentLaunchResult>`
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 59bc5e9b04.
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 <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
0cd05bc3d9
commit
97aa5ff19b
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599",
|
||||
"adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5",
|
||||
"scenarioSha256": "7e4c5bb29e0f630cda8a09233575b9295e485f3d3e315ebdc0458c69515fcfc7",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599",
|
||||
"adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5",
|
||||
"scenarioSha256": "1d7713cf4c23d053105c2abb02340d81d5eb689f4311a0984932d8ebd031b4ce",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599",
|
||||
"adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5",
|
||||
"scenarioSha256": "994ea8b4ddb05774a8c2d5902bb68bf5e8f25399a787262b8f23f458f2790698",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599",
|
||||
"adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5",
|
||||
"scenarioSha256": "a89bdf93df71a958810aba72c80e42f663644781a29e934898e2ddf86c5dd5d5",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
},
|
||||
|
||||
+64
-60
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599",
|
||||
"adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5",
|
||||
"scenarioSha256": "662c3e04e31bce5757f09f91e3e3739fb9d57767b7443be4dc936705b64b1432",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599",
|
||||
"adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5",
|
||||
"scenarioSha256": "8ae9e1dbb32d404eac9e01f71dacf1c37497030220a8e988c0093bb7ed2d159b",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599",
|
||||
"adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5",
|
||||
"scenarioSha256": "5c4c890e4c71e80fa8847a5e29700fc9df3ac3bd634bad6289db37522fadd621",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599",
|
||||
"adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5",
|
||||
"scenarioSha256": "a699a0a5b128fa422dab0c7557b5aa18599b2d23fa6685cdcc02e17edf328af1",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599",
|
||||
"adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5",
|
||||
"scenarioSha256": "a5e812cd508826b3f01ec3798c621ab4303de6536a364113f01a4770dd197bb5",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599",
|
||||
"adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5",
|
||||
"scenarioSha256": "b10ff86086c134284cb0446e8857cd4b55f5ff2bd0507388ec659a95f25e2a19",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599",
|
||||
"adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5",
|
||||
"scenarioSha256": "373ea3743dac4e0845df01d5c8f75c909563b8c517f3858293a478234dc9ca5c",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "411f2288f09b7940ceb46304c7fc3325e248bf009ff3a7cc12839d521cfad599",
|
||||
"adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5",
|
||||
"scenarioSha256": "dfbacbd6392ae8e8199550952fe917e7c01182349df6c99a06eb0682cfd9175c",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -56,6 +56,7 @@ export function useNewWorkspaceCreateSubmit(args: {
|
||||
trustedOrcaHooks: PersistedTrustedOrcaHooks
|
||||
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
|
||||
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
|
||||
getAgentLaunchSupport: () => Promise<boolean>
|
||||
transitionDrawer: (view: Exclude<NewWorktreeDrawerView, 'transition'>) => void
|
||||
setError: Dispatch<SetStateAction<string>>
|
||||
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)
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<boolean>
|
||||
}
|
||||
|
||||
/** `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')
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<boolean>
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<boolean>
|
||||
}
|
||||
|
||||
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<boolean>
|
||||
): 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<boolean>
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
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<boolean>
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
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
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
} {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<WorktreeCreateIdempotencySupport | false>
|
||||
getAgentLaunchSupport: () => Promise<boolean>
|
||||
} {
|
||||
const [tasksSupported, setTasksSupported] = useState(false)
|
||||
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(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 }
|
||||
}
|
||||
|
||||
@@ -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<TuiAgent | null> {
|
||||
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<RpcResponse> {
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof useSubmit>
|
||||
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<typeof useSubmit>[0])
|
||||
})
|
||||
return {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<AgentLaunchResult['outcome']> {
|
||||
): 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'
|
||||
}
|
||||
|
||||
@@ -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<typeof AgentLaunch>
|
||||
export {
|
||||
AgentLaunch,
|
||||
type AgentLaunchParams
|
||||
} from '../../../../shared/rpc-contract/agent-launch-params'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, unknown>) => ({
|
||||
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<string, unknown>): Promise<StructuredCreateReply> => ({
|
||||
ok: true,
|
||||
value: { sessionId: 'sess-1' }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('./structured-agent-session-create', () => ({
|
||||
createStructuredAgentSessionForWorktree: (args: Record<string, unknown>) =>
|
||||
@@ -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<string, Promise<unknown>>()
|
||||
@@ -73,9 +85,13 @@ function runtimeStub(
|
||||
createManagedWorktree: vi.fn(async (args: Record<string, unknown>) => ({
|
||||
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' } })
|
||||
|
||||
@@ -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<T> = 0 extends 1 & T ? true : false
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -252,9 +252,7 @@ export function BrowserPane({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showUserAgent ? (
|
||||
<BrowserUserAgentSetting hostId={settingsFocusedHostId} />
|
||||
) : null}
|
||||
{showUserAgent ? <BrowserUserAgentSetting hostId={settingsFocusedHostId} /> : null}
|
||||
|
||||
{showLinkRouting ? (
|
||||
<BrowserLinkRoutingSetting
|
||||
|
||||
@@ -73,7 +73,7 @@ export type AgentLaunchIntent = {
|
||||
/** The surface the host actually created. */
|
||||
export type AgentLaunchOutcome =
|
||||
| { kind: 'structured'; sessionId: string; handle: string }
|
||||
| { kind: 'terminal'; handle: string; warning?: string }
|
||||
| { kind: 'terminal'; handle: string }
|
||||
|
||||
/** Whether the launch text was delivered, for a caller that needs to report or retry it. */
|
||||
export type AgentLaunchPromptReceipt = {
|
||||
@@ -85,6 +85,18 @@ export type AgentLaunchResult = {
|
||||
outcome: AgentLaunchOutcome
|
||||
/** The workspace the agent runs in, resolved or created. */
|
||||
worktreeId: string
|
||||
/**
|
||||
* The launch completed but something in it did not: a startup terminal that failed to spawn,
|
||||
* untracked files that could not be copied. `worktree.create` returns this at the top level and
|
||||
* mobile already surfaces it, so a launch that drops it lands the user on a workspace that is
|
||||
* quietly incomplete.
|
||||
*
|
||||
* Top level rather than on the outcome, and deliberately the ONLY place a launch warning lives:
|
||||
* it is produced by the create as often as by the surface, it applies to a structured session
|
||||
* and a terminal alike, and a reader should not have to branch on `outcome.kind` to discover
|
||||
* that the workspace it just opened is missing something.
|
||||
*/
|
||||
warning?: string
|
||||
/** Why the outcome is what it is — always populated, so a downgrade is never silent. */
|
||||
receipt: AgentLaunchModeReceipt
|
||||
prompt?: AgentLaunchPromptReceipt
|
||||
@@ -122,12 +134,13 @@ export const AGENT_LAUNCH_RESERVED_CREATE_FIELDS = [
|
||||
/** Strips the reserved agent fields from a create payload. Callers migrating from
|
||||
* `worktree.create` pass their existing params; this keeps a stale `startupAgent` from
|
||||
* re-creating the agent-first path the router exists to replace. */
|
||||
export function withoutReservedAgentCreateFields(
|
||||
create: Readonly<Record<string, unknown>>
|
||||
): Record<string, unknown> {
|
||||
export function withoutReservedAgentCreateFields<Create extends Readonly<Record<string, unknown>>>(
|
||||
create: Create
|
||||
): Create {
|
||||
const stripped: Record<string, unknown> = { ...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
|
||||
}
|
||||
|
||||
@@ -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<typeof AgentLaunch>
|
||||
+2
-1
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user