fix(delegation): draw a resumed sub-agent as one card, live and in history
Resuming a canceled delegation drew a bare "Resuming task <uuid>" row — wrapped in a "Used 1 tool" group — directly above a second, full sub-agent card, and reopening the conversation from history lost that second card entirely. Both symptoms are the same defect. The broker writes a resume's running meta onto the ORIGINAL delegate_to_agent call's tool_use_id, which belongs to an earlier turn that TurnComplete has already cleared, and `upsert_tool_call` inserts on miss: the emit minted a brand-new, otherwise-empty tool call carrying nothing but that meta, and `inferLiveToolName` reads "has codeg.delegation meta" as a delegation, so the empty entry rendered as a whole card. Being live-only state, it never survived a reload. `write_meta` now emits through `emit_with_state_gated`, so the predicate — the id is still in `active_tool_calls` — is evaluated under the same write lock that applies the event and no TurnComplete can land between the check and the insert. This also settles the same family of ghost for a `delegate_to_agent` child that finishes after its parent turn ends. Nothing is lost by dropping those writes: both cards recover their terminal state from the delegation events while live and from `inject_delegation_meta` on a cold reload, as they already did. The resume itself is now the sub-agent card rather than a title above one. `ResumedDelegationCard` renders the shared `DelegationCardRow` — extracted verbatim from `DelegatedSubThread`, which becomes a thin shell over it — wearing a corner marker on the avatar, and expands to the resume reason alone. Its identity comes from the report the call already returned (`agent_type`, `child_conversation_id`) plus, once reloaded, the child's current status and task text: `inject_delegation_meta` gained a `resume_delegation` branch that binds by the `task_id` in the call's arguments, so a reloaded card tracks where the task really stands instead of the `running` its own ack froze. Reading that argument shares `acp::lifecycle`'s wrapper-key list rather than restating it, and falls back to the tolerant scan for a preview the parsers truncated, so CodeBuddy's `params`, Antigravity's `arguments` and Cursor's `args` envelopes all resolve. `resume_delegation` also joins the classifier's workbench names, which is what drops the stray "Used 1 tool" shell, with a test pinning that list against the one that owns these cards. A REFUSED resume must not read as a revived one. `not_resumable_report` answers with the task's actual status and its full identity, so status alone cannot tell it from a real resume and the identity fields alone would satisfy the card; the refusal is recognized by `error_code`, and the card falls back to the plain tool row that states "Not resumed: …" while the overlay skips it. Where no structure survived — OpenCode drops `structuredContent` wholesale — the verdict falls back to the message prefixes the broker writes for exactly that case, anchored at the start of the text because a resume whose child completed during setup reports that child's own prose there. Finally, a task-id lookup is only ever an enrichment. `DelegationProvider` spans the workspace and a task id is a value the model wrote, so a foreign id would otherwise pull another conversation's agent, task and "open conversation" button onto this card even though the backend correctly refuses it. The match now stands only when the call's own result names the same child conversation, or affirms the resume in words where no child id survived; a direct `parentToolUseId` hit is still trusted outright, being the binding key itself.
X
xintaofei committed
9ab2f68c58bdcfd4d3773e497c2d066fbed01183
Parent: 2c382d2