SIGN IN SIGN UP

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