Skip to content

feat(mobile): show a delegated subagent's work in the transcript - #3920

Open
braindeadz wants to merge 1 commit into
openBitFun:mainfrom
braindeadz:feat/mobile-subagent-branch-cards
Open

braindeadz wants to merge 1 commit into
openBitFun:mainfrom
braindeadz:feat/mobile-subagent-branch-cards

Conversation

@braindeadz

@braindeadz braindeadz commented Nov 14, 2026 •

Copy link
Copy Markdown

Summary

A delegated subagent runs in its own host session. The mobile client only ever drew
the tool row that launched it, because the child session id the host stamps on that
row was read by SessionRecordReplica and then dropped. This PR keeps that id and
follows the child on demand, so the worker's own steps appear inside the card that
launched it, with the worker's own lifecycle, and a child that cannot be read says so
on its card instead of emptying or failing the conversation.

Fixes #

Type and Areas

Type: Feature (with a UI/UX change to an existing surface).

Areas: mobile Android app, mobile shared core (core-feature, core-protocol).

Shared code only: no Rust core, relay, desktop or web UI changes. The shared follower is
opt-in per platform: Android is the only client that turns it on in this PR, and the
consequences for the other clients are spelled out in
Known limitations. HarmonyOS does not consume the Kotlin module.

Motivation / Impact

The problem. A host spawns a child agent into a separate session. The parent
transcript carries only the launching tool item, and the subagentSessionId written on
that item was parsed and discarded while mapping the record. Consequences on the phone:

  • the child's reasoning, tool calls and answer were unreachable — the card could only
    show the spawn call's own status and result preview;
  • because a host answers an asynchronous spawn as soon as the worker starts, that
    status says nothing about the worker, so a working child was drawn as "Done";
  • the only lifecycle vocabulary was a boolean (subagentFailed(status)), which folded
    every non-running status into "completed" — a queued, finishing or tool-calling
    worker was printed as finished.

What the user sees now.

Before After
A "Task"/AgentSpawn card with a status word and a short result preview The same card, with the worker's identity avatar and its own lifecycle word (Running / Finishing / Queued / Done / Cancelled / Failed / Status unknown)
Nothing behind the card, or a collapsed one-line preview Opening the card shows the worker's own steps, in the worker's order, read from the child session
A working child printed as "Done" A queued child says Queued, a finishing child says Finishing, and a status the client cannot read says Status unknown instead of guessing
A child that could not be read looked like an ordinary finished tool row The card says "Worker steps unavailable", or "This desktop cannot share worker steps" when the host has no on-demand streams, and never invents a step
— The parent conversation stays readable while a child stream fails; only that card reports it

Design.

  • RemoteToolStatusResponse gains an optional subagent_session_id; SessionRecordReplica
    keeps it on the mapped tool instead of dropping it, and the item stays marked as a
    branch.
  • SubagentBranchCoordinator (new, core-feature) owns the child streams. Discovery
    happens in decorate(), which is called from exactly one place — render() in
    RemoteSessionStore — so a child is opened only when the parent transcript names one,
    never speculatively.
  • decorate() replaces two things on the owning item and nothing else: the branch items
    (branchItems) and the status word (ownerItem). MessageBlockPresentation then
    draws the item as the card it already knew how to draw.
  • The card's lifecycle comes from the child's own turns (turnStatus/childStatus),
    never from the spawn call's answer, and never from silence: a stream that failed
    before publishing anything leaves the launching item's own status standing.
  • The card's presentation is subagentLiveness(status) (new enum SubagentLiveness)
    instead of the removed boolean subagentFailed(status).
  • Attaching is on demand: RemoteSessionIntent.EnsureSubagentAttached(ownerToolCallId)
    is dispatched when a card is first opened (MessageBlockList → LocalSubagentAttach
    → ConversationView), and handled by RemoteSessionStore.ensureSubagentAttached.
  • The follower is opt in. RemoteSessionStore takes followSubagentBranches
    (internal constructor, default false), threaded through
    AccountStore.createSessionStore(scope, followSubagentBranches); Android passes true
    at its single construction site and every other client keeps the existing overload, so
    the coordinator is never even created there.

Compatibility.

  • Older host, no child id. SessionRecordReplica maps a missing/empty
    subagentSessionId to null; SubagentBranchCoordinator.openBranch starts with
    if (childSessionId.isEmpty() || ownerToolCallId.isEmpty()) return, so no stream is
    ever opened; with no branches, decorate() returns messages unchanged
    (if (branches.isEmpty()) return messages); and MessageBlockPresentation.walk
    routes an ordinary tool item to MessageBlock.Tools (the plain tool row) unless
    isSubagent(entry) is true. What that preserves is the transcript and the number of
    streams opened — exactly none. What it does not preserve is the card affordance,
    because a task/AgentSpawn row is already a delegation card on any host (that
    predates this PR): a still-running such row whose worker has no readable branch is
    now expandable, and expanding it shows an empty panel and dispatches an attach that
    changes nothing. Details and the reason it is kept are in
    Known limitations.
  • The follower is opted in per platform — iOS is untouched. RemoteSessionStore takes
    followSubagentBranches, which stays off unless a caller asks for it. Android is the
    only caller that asks: AccountViewModel, its single construction site, through the new
    AccountStore.createSessionStore(scope, followSubagentBranches) overload. iOS keeps
    createSessionStore(scope:), which leaves the flag at its default, so
    subscribeSessionUpdates never creates a SubagentBranchCoordinator. iOS therefore
    draws and reads exactly what it drew at the base commit: nothing is decorated, no child
    stream is opened, no failure is recorded and no branch can go stale.
    The one delta that reaches every platform, iOS included and whether or not the flag is
    on, is data-level: SessionRecordReplica now keeps the host's subagent_session_id on
    the launch tool, so a persisted launch row gains that one optional field. It is safe in
    both directions — the field has a default and the payload codec is
    Json { ignoreUnknownKeys = true }, so an older build still restores a row written by
    this one. Keeping the flag off there is deliberate: shipping the shared streams to iOS
    while leaving out the two affordances that finish them (no EnsureSubagentAttached, no
    subagentFailures surface) would be a half-working path — a cap-released branch stranded
    on a stale prefix and a recorded failure nobody can see — so it is off there until the
    Swift client is wired. Follow-up: wire the expansion intent and the per-card failure map
    into Swift, then pass followSubagentBranches = true at the Swift construction site.
  • Compile safety elsewhere. iOS only reads RemoteSessionUiState.Ready
    (as? RemoteSessionUiStateReady and property access) and never constructs it, never
    constructs RemoteToolStatusResponse, and never constructs MessageBlock.Subagent;
    a secondary constructor keeps the previous positional initializer valid, and
    RemoteToolStatusResponse's new field is optional with a default. HarmonyOS does not
    consume the Kotlin module at all — it models the relay payload with its own TypeScript
    interface (RemoteToolStatusResponse in RemoteModels.ets) — so neither the new
    field nor the new intent reaches it.
  • Child steps are not persisted. toPersisted strips a child's items
    (withoutChildSteps) and keeps the child session id on the tool, so a session
    restored from this device's cache still finds the branch and re-reads it from the
    host on demand. This is also the only persistence change this PR makes on any
    platform, including iOS with the flag off.

Verification

Environment: Windows, JDK 17, Android SDK, Node 22.23.5, Gradle wrapper from the repo.
No device or emulator is attached for this revision (the two card suites were run on a
device at 7d97a522a).

Baseline (untouched checkout at the base commit, before any change):

  • shared/: ./gradlew.bat --console=plain :core-domain:jvmTest :core-feature:jvmTest
    → BUILD SUCCESSFUL. core-domain 13 classes / 125 tests / 0 failures;
    core-feature 44 classes / 530 tests / 0 failures.

  • android/: ./gradlew.bat --console=plain :app:testDebugUnitTest → BUILD FAILED with
    one failing test, 13 classes / 65 tests / 1 failure:

    ProcessPresentationTest > completedWorkSummarizesWithoutHidingLiveOrFailedTools FAILED
        java.lang.AssertionError: expected:<3> but was:<1>
        at com.openbitfun.mobile.app.ui.chat.ProcessPresentationTest
            .completedWorkSummarizesWithoutHidingLiveOrFailedTools(ProcessPresentationTest.kt:44)
    

    This is a pre-existing disagreement on the base commit, not caused by this PR: the
    test expects a FAILED tool to break the compact activity summary into three groups,
    while ToolCard.foldIntoSummary (ConversationPresentation.kt) accepts
    ToolPhase.FAILED as foldable, so the five blocks fold into one group. This PR does
    not touch foldIntoSummary and does not fix it.

After this change (same commands, same machine):

  • shared/: BUILD SUCCESSFUL. core-domain 13 classes / 125 tests / 0 failures;
    core-feature 45 classes / 557 tests / 0 failures (+27, all from the new
    SubagentBranchCoordinatorTest; MessageBlockPresentationTest 27 tests, 0 failures).
  • android/: :app:testDebugUnitTest → still exactly the same single failure,
    same assertion message, same test:
    ProcessPresentationTest.completedWorkSummarizesWithoutHidingLiveOrFailedTools
    java.lang.AssertionError: expected:<3> but was:<1> (now at line 40, because four
    lines were removed above it). 14 classes / 73 tests / 1 failure (+8 net:
    SubagentLivenessTest +9, ProcessPresentationTest −1). No test that passed at
    baseline fails now, and the pre-existing failure is unchanged.
  • android/: ./gradlew.bat --console=plain :app:compileDebugAndroidTestKotlin → BUILD
    SUCCESSFUL for this revision. SubagentBranchCardTest (4/4) and SubagentLivenessCardTest
    (7/7) passed on a real Android device at revision 7d97a522a; this revision's changes
    to those files' shared inputs and to ChatMessageBubbleTest are compile-checked only,
    because no device is attached here
    .

Repository guards (from the repo root, Node 22.23.5):

Command Result
node scripts/check-mobile-architecture.mjs PASS — "Mobile shared core architecture contracts are satisfied."
node --test scripts/mobile-client-build.contract.test.mjs PASS — 2 tests, 2 pass, 0 fail
node scripts/mobile-ui-design-system.mjs --check PASS — "Contract and generated files are in sync."
node scripts/mobile-ui-preview.mjs --check SKIPPED — [mobile-ui-preview] Missing canonical design-system asset for /design-system-tokens.css. It requires design-system/packages/*/dist/*.css, which are gitignored build outputs of a package build that this environment does not have (no node_modules, and no install was performed).
node scripts/i18n-audit.mjs SKIPPED — [i18n:audit] ERROR Failed to load TypeScript for i18n audit: Cannot find module 'typescript' (no node_modules in this environment; nothing was installed).
node scripts/audit-frontend-colors.mjs --kind native-mobile FAILS for an environment reason unrelated to this PR — Frontend color governance failures: - exclusions[2].path does not exist: src/web-ui/public/monaco-editor. That path is a vendored directory that is absent from the base commit's tree as well; the script is untouched by this PR and validates its exclusion list before filtering by kind.
node scripts/check-git-object-sizes.mjs --base upstream/main --head HEAD PASS — "Checked 24 Git blobs; no unapproved objects exceed 5242880 bytes."

Honest test level. AI-assisted implementation. Verification level: unit, compile, and
one real-device run. The shared JVM suites and :app:testDebugUnitTest were re-run for this
revision. The two card instrumented suites passed on a real Android device at revision
7d97a522a (4/4 and 7/7) and were not re-executed after the amendment; the amendment changed
shared unit tests, a test-only composition local in ChatMessageBubbleTest, and a
remembered callback, not the drawing assertions. Nothing here was run against a live
desktop host, and no screenshot is attached.

Reviewer Notes

Scope of the diff: 24 files — 9 new (SubagentBranchCoordinator.kt,
SubagentBranchCoordinatorTest.kt, SubagentLiveness.kt, SubagentAvatar.kt,
SubagentLivenessTest.kt, SubagentBranchCardTest.kt, SubagentLivenessCardTest.kt,
values/strings_subagent.xml, values-zh/strings_subagent.xml) and 15 modified
(MessageDtos.kt, SessionRecordReplica.kt, MessageBlockPresentation.kt +
MessageBlockPresentationTest.kt, RemoteSessionUiState.kt, RemoteSessionStore.kt,
AccountStore.kt, AccountViewModel.kt, ConversationView.kt, MessageBlockList.kt,
ProcessPresentation.kt + ProcessPresentationTest.kt, Theme.kt,
ChatMessageBubbleTest.kt, AGENTS.md). AccountStore.kt and AccountViewModel.kt are
the opt-in plumbing (followSubagentBranches); ChatMessageBubbleTest.kt is the only
pre-existing instrumented file touched, and only to keep its fixtures from waiting on the
new endless pulse. No iOS, HarmonyOS, Rust, relay, desktop or web UI file is in the diff —
MobileCoreAdapter.swift is not modified.

New tests and what they cover.

  • SubagentBranchCoordinatorTest (JVM, core-feature, 27 tests). The path a reviewer
    is most likely to doubt is covered by these exact tests:

    Path Test
    The id survives mapping aChildSessionIdSurvivesTheParentTranscriptAndMarksTheBranch
    Steps attach to the owner, status follows the child theChildsStepsAttachToTheOwnerItemAndItsStatusFollowsTheChildsTurns
    A live/queued/finishing worker is never "done" aChildTurnThatIsStillTakingItsTurnIsNotReportedAsFinished, aChildTurnThatIsOnlyQueuedIsReportedAsWaitingAndKeepsItsStream, aChildTurnThatIsFinishingIsReportedAsFinishingAndKeepsItsStream
    Gap re-read aChildGapReReadsThatChildOnceWithoutDuplicatingItsSteps
    Child failure before content, and after content aChildStreamThatFailsBeforeAnyContentKeepsTheLaunchStatusAndReportsOnce, aChildStreamThatDiesAfterPublishingStepsKeepsThemAndStopsClaimingToBeRunning
    Child failure keeps the session readable anUnsupportedChildStreamLeavesTheConversationReadableAndIsReportedOnItsCard
    Cap and re-attach theConcurrencyCapIsRespectedAndAnOpenedCardCanAlwaysReattach, aRunningChildEvictedByTheCapIsFollowedAgainWhenItsCardIsOpened
    Foreground release and return leavingTheForegroundReleasesChildStreamsAndReturningReattachesThem
    A card's failure entry is cleared by a real re-read aCardStopsReportingItsWorkerOnceTheStreamIsReadAgain
    …and kept when no read happened openingACardWhoseHostCannotServeChildStreamsKeepsTheExplanationItHas
    …and cleared once the card draws steps aCardThatIsShowingItsWorkersStepsStopsReportingAFailure
    Session switch / late child error switchingOrEndingTheSessionClosesEveryChildStream, switchingOrClosingTheConversationStopsEveryChildStream, aLateChildErrorFromAClosedSessionLeavesTheNewSessionUntouched
    Not persisted aChildsStepsAreNeverStoredInTheParentsTranscript
    Off by default: no stream, the host's own transcript, inert attach, no failure subagentBranchesAreOffByDefaultAndLeaveTheTranscriptUntouched
    Parent publishes its own branch items aHostThatMarksItsOwnSiblingRecordsKeepsThePathItAlwaysUsed, aHostThatStartsPublishingMarkedSiblingsStopsBeingFollowed
    A record stamped with another session is rejected aChildRecordStampedWithAnotherSessionIsRejectedInsteadOfReduced

    Both ways a card stops reporting are covered, at the level where the code lives
    (RemoteSessionStore.ensureSubagentAttached → clearSubagentFailure, and
    reconcileSubagentFailures from render()): the positive re-read, the no-read case
    that must keep the explanation, and the "already drawing steps" case.

  • SubagentLivenessTest (JVM, app, 9 tests) — the host vocabulary → reading mapping,
    including that an unreadable status is UNKNOWN rather than "Done", and that a
    worker's identity is derived from the delegation and stable (a pinned value guards the
    derivation).

  • SubagentBranchCardTest (instrumented: passed 4/4 on a real device at revision
    7d97a522a, not re-run after the amendment) — the drawing path:
    aDelegationCardShowsTheWorkersStepsWhileTheChildIsStillRunning (steps behind the
    reader's expansion, and opening the card asks for the worker by its owner tool call
    id), aSettledChildKeepsItsStepsAndShowsTheSettledReading,
    aDesktopWithoutOnDemandStreamsSaysSoAndInventsNoSteps and
    aChildStreamThatFailedBeforeAnyContentKeepsTheLaunchStatusAndSaysStepsAreUnavailable
    (the two "no steps" readings use their own copy and invent no step).

  • SubagentLivenessCardTest (instrumented: passed 7/7 on a real device at revision
    7d97a522a, not re-run after the amendment) —
    everyReadingIsDrawnInItsOwnWords, aQueuedWorkerIsNeverReportedAsDone,
    everyReadingHasItsOwnMarkAndNotOnlyItsOwnWord (six distinct marks, compared in
    pixels), twoSpawnCardsWithOneTitleStillHaveTwoFaces,
    theHeaderSaysWhatTappingItDoesAndTheAvatarSaysNothingTwice,
    aRunningWorkerMovesAndOnlyItsMarkMoves (the pulse is confined to the state mark) and
    aReaderWhoRemovedAnimationsGetsTheSameCardTwice.

Coverage removed and replaced — please read. ProcessPresentationTest.statusPreservesFailuresInsteadOfCallingThemDone
is deleted because it asserts on subagentFailed(status), which no longer exists: the
card reads subagentLiveness(status) instead. That test passed at baseline, so this is
a real removal of a passing test, not a deletion of the pre-existing failure (which is a
different test in the same class, still present and still failing). Its coverage is
replaced by SubagentLivenessTest.aWorkerThatIsStillTakingItsTurnIsNeverReportedAsDone,
...aTurnThatIsWrappingUpIsItsOwnReadingAndStillInFlight,
...aQueuedWorkerIsWaitingRatherThanRunningOrDone and ...terminalOutcomesKeepTheirOwnReading,
which assert the same failure vocabulary (failed, error, timeout, cancelled,
canceled, rejected → ERROR/CANCELLED, not COMPLETED) plus the states the old
boolean could not express. The remaining three tests in ProcessPresentationTest are
untouched.

Code paths to look at.

  • Default off: the followSubagentBranches parameter on RemoteSessionStore (internal
    constructor, default false), the if (followSubagentBranches) guard around the
    coordinator in subscribeSessionUpdates (so subagents stays null and
    render()'s subagents?.decorate(records.messages()) ?: records.messages() is the
    host's own transcript), the null-safe setForeground/finally calls, the inert
    ensureSubagentAttached on a null coordinator, the new
    AccountStore.createSessionStore(scope, followSubagentBranches) overload, and
    AccountViewModel as the only true in the tree.
    subagentBranchesAreOffByDefaultAndLeaveTheTranscriptUntouched asserts the consequence
    with the default: no attempt at the child, the owner item equal to the un-decorated
    replica item (subagentSessionId kept, no child items, the host's own status),
    EnsureSubagentAttached provably inert (assertSame on the state value), and
    Ready.subagentFailures empty even with the child stream rigged to fail.
  • Older host: openBranch's early return, decorate's branches.isEmpty() early
    return, MessageBlockPresentation.walk's isSubagent(entry) branch.
  • Cap and re-attach: MAX_CONCURRENT_CHILD_STREAMS, acquireSubscriber,
    releaseCandidate (settled before working, oldest first), waitForSlot,
    drainWaiters (newest first), ensureAttached, and RemoteSessionStore.ensureSubagentAttached
    which only clears a card's failure when a read really happened.
  • Child failure: childStreamFailed → onError → RemoteSessionStore.reportSubagentFailure,
    which copies Ready and never calls handleFailure; reconcileSubagentFailures
    drops the report once the card is drawing steps.
  • Session switch and late errors: subscribeSessionUpdates calls subagents?.endSession()
    and nulls the field before cancelling the old job; the job's finally calls
    branches?.endSession() and only clears the field if (subagents === branches);
    childStreamFailed returns early on an empty parentSessionId or a stale
    subscription token; applyChildEvent checks the record's session_id against the
    subscription.
  • Not persisted: withoutChildSteps in toPersisted.
  • Foreground: SubagentBranchCoordinator.setForeground releases every subscribed
    child on the way out, re-attaches non-settled ones on the way back (a settled worker
    has nothing left to publish), and is reached from RemoteSessionStore.setForeground.
  • Composition local stability: ConversationView holds the attach callback in a
    remember and reads onIntent through rememberUpdatedState, so a recomposition
    does not hand every block-list reader a new lambda identity.

The one pre-existing instrumented file this PR touches. ChatMessageBubbleTest
renders three running delegation cards, and a running worker's state mark now pulses
forever — a compose test cannot idle on a clock that never stops. Its Bubble helper
therefore provides LocalReduceMotionOverride = true, which changes only the movement,
not the reading printed on the card. Without it those pre-existing assertions could hang
or time out on a device; that override is compile-checked only in this revision; the file
itself passed on a real device at revision 7d97a522a, before the override was added, so
the fix is reasoned from the compose test contract rather than measured.

Risks worth attacking.

  1. Eight concurrent child streams per parent, 64 tracked branches, and each tracked
    child holds a reduced transcript in memory. The cap and the bound are argued in
    comments, not measured on a device.
  2. A stream that dies after publishing a partial turn keeps the steps but drops the
    running reading (falls back to the launching item's status) — deliberate, but it
    means a card can stop saying "Running" while the worker is in fact still alive.
  3. The card affordance changed for rows that have no branch at all. The trigger is
    wider than "an older desktop": any running task/AgentSpawn row is already drawn
    as a delegation card (MessageBlockPresentation.isSubagent matches on name), so a
    running card whose worker has no readable branch — an older host that names no child
    id, a host whose branch was never tracked (over MAX_TRACKED_CHILD_BRANCHES), or one
    released with nothing published — now offers expansion, expands to an empty panel, and
    dispatches an attach that changes nothing. No stream is opened and no failure is
    recorded, so nothing is wrong beyond the affordance; see
    Known limitations for why it is kept.
  4. EnsureSubagentAttached is dispatched on the first expansion of any card; on a
    healthy host ensureAttached returns false and nothing happens, but the intent is
    still sent through the store on every session.
  5. The card-level instrumented tests drive MessageBlockList with the two composition
    locals supplied directly, not through ConversationView (the app module may not name
    core-domain/core-protocol types, which is what check-mobile-architecture.mjs
    enforces). ConversationView's wiring of those locals from the state is therefore
    compile-checked only — including the remembered attach callback.
  6. The pre-existing completedWorkSummarizesWithoutHidingLiveOrFailedTools failure will
    still be red in this PR's unit-test run. It is unrelated, and it is not fixed here on
    purpose — deciding whether foldIntoSummary or the test is wrong is a separate
    change.
  7. setForeground(true) deliberately does not re-open a settled worker (it has nothing
    left to publish, and spending one of eight lanes on it would starve the ones still
    working). The cost: a settled worker that is continued while the phone is in the
    background is not followed again by returning to the foreground — it is followed
    again when the parent names a new launch for it or when its card is opened.
  8. The feature is Android-only in practice: the other clients do not run the follower at
    all, so they gain nothing here (and lose nothing) — see
    Known limitations.

Known limitations

The follower is off for every client but Android. Child-stream following is opted in
through RemoteSessionStore.followSubagentBranches, and Android is the only client that
passes true (from AccountViewModel, via
AccountStore.createSessionStore(scope, followSubagentBranches)). iOS is not modified:
MobileCoreAdapter.swift still calls createSessionStore(scope:), so the flag keeps its
default, no SubagentBranchCoordinator is created, and iOS keeps its previous behaviour —
no decoration, no child streams, no recorded-but-invisible failures and no stale prefix (the
only cross-platform delta is the optional subagent_session_id now stored on a launch row,
described in Child steps are not persisted). That is the intended state for this PR
rather than an end state: following a
worker without a way to re-attach a released branch and without a surface for a per-card
failure would be a half-working path, so the Swift client turns it on when it is ready.
Follow-up: wire the same expansion intent (EnsureSubagentAttached) and the same
subagentFailures map into the Swift client, then pass followSubagentBranches = true
there. Everything the Swift side needs already exists store-side and is client-agnostic.

An expandable card with nothing to expand. The rule that makes a card openable is
children.isNotEmpty() || text.isNotBlank() || stepsUnavailable != null || (ownerToolCallId.isNotEmpty() && running). The last term exists because a tracked
worker that has published nothing yet (the cap can release a branch before its first
record) must still be openable — opening it is the only gesture that re-attaches it.
The card cannot tell "tracked, nothing yet" from "not tracked at all", because a
tracked branch with no items leaves the owner item byte-identical to an untracked one.
So any running delegation row without a branch is now expandable to an empty panel. It
is kept rather than gated because gating it needs a "this owner is tracked" signal
threaded from the coordinator to the card — a new field on the shared presentation plus
plumbing through the coordinator and the card — which is a larger change than this PR.
Gating it here would also remove the only way to re-attach a worker that lost its lane
before it said anything.

Checklist

  • This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts.
  • Relevant verification is recorded above, or skipped checks are explained.
  • User-facing strings, docs, and locales are updated where applicable.

…anscript

A host keeps a spawned child agent in its own hidden session. The parent
transcript only carries the tool row that launched it, and the
`subagentSessionId` the host stamps on that row was read by
SessionRecordReplica and then dropped, so the child's work was unreachable
from the phone and a delegation card could only ever show the spawn call's
own outcome.

Keep the id on the mapped tool (optional, so an older host is unaffected)
and follow the child from a new core-feature coordinator:
SubagentBranchCoordinator opens one on-demand child stream per owner,
attaches the child's own items as the branch of the item that launched it,
re-reads the branch from the host's newest page on a stream gap, caps
concurrent child streams at eight with oldest-first eviction, and exposes
EnsureSubagentAttached so opening a card asks again for a stream that the
cap or a failure released. Child steps are never stored on the device:
toPersisted() strips them (withoutChildSteps) and the child is re-read on
demand.

A child stream failure is reported per card (Ready.subagentFailures, keyed
by owner tool call id) rather than replacing a readable conversation with a
session-level error: the session stays Ready and the launching item keeps
its own status. The card's liveness/status presentation replaces the old
boolean subagentFailed(), so a worker that is queued, finishing or calling a
tool is no longer printed as done, and a status this client cannot read is
drawn as unknown instead of guessed as completed.

Following a child is opted in per platform, because it is only half a
feature without the card around it: a client that cannot ask for a re-attach
and cannot show why a worker was unreadable would keep a released branch's
stale prefix and record failures nobody sees. RemoteSessionStore takes
followSubagentBranches, off unless a caller asks for it, and AccountStore
gained an overload to pass it through; Android is the only caller that
passes true, at its single construction site. With the flag off the
coordinator is never created, so decorate() is skipped, the published
transcript is the host's own item for item, and EnsureSubagentAttached is
inert. iOS is therefore behaviourally unchanged -- createSessionStore(scope:)
keeps the default, so no decoration, no child streams, no
recorded-but-invisible failures and no stale prefix -- and a Swift follow-up
can switch it on once the attach trigger and the per-card failure surface are
wired there. The only delta that reaches iOS is data-level: a persisted
launch row now carries the host's optional subagent_session_id, which the
payload codec's ignoreUnknownKeys keeps forward- and backward-compatible.
Compile safety holds everywhere: iOS only reads
RemoteSessionUiState.Ready and never constructs it, a secondary constructor
preserves the previous positional initializer, RemoteToolStatusResponse's new
field is optional, and HarmonyOS models the relay payload with its own
TypeScript interface. An older desktop that sends no subagent_session_id
opens no stream at all and the launch item keeps the plain tool row it has
always had.

Testing level: AI-assisted implementation, unit, compile and one real-device
run. The shared JVM suites pass: 125 tests in core-domain and 557 in
core-feature, 27 of them in SubagentBranchCoordinatorTest, which covers both
ways a card stops reporting its worker (a real re-read, and steps arriving)
and asserts that branches are off by default and leave the transcript
untouched. :app:testDebugUnitTest still shows exactly the one failure that
official main already has
(ProcessPresentationTest.completedWorkSummarizesWithoutHidingLiveOrFailedTools,
expected:<3> but was:<1>), which this change neither causes nor fixes. The
obsolete ProcessPresentationTest.statusPreservesFailuresInsteadOfCallingThemDone
is removed with subagentFailed(), and its coverage is replaced by
SubagentLivenessTest. ChatMessageBubbleTest's fixtures now ask for reduced
motion, because a running worker's state mark pulses forever and three of
those pre-existing fixtures draw one. At revision 7d97a522a the two card
instrumented suites were executed on a real Android device and passed:
SubagentBranchCardTest 4/4 and SubagentLivenessCardTest 7/7. This revision
was not re-run on a device: its added coordinator unit tests, the
ChatMessageBubbleTest reduce-motion override and the ConversationView
remembered callback are compile-checked only. Nothing was run against a live
desktop host.

Co-authored-by: Driss <[email protected]>
@braindeadz
braindeadz force-pushed the feat/mobile-subagent-branch-cards branch from 980e926 to 15a4a7c Compare November 14, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant