Skip to content

Name the Bot on a hop that was refused, retried or lost, not only on one that landed - #306

Merged
davidmckayv merged 3 commits into
CopilotKit:mainfrom
kevin9327:fix/handoff-refusal-names-bot
Aug 31, 2026
Merged

Name the Bot on a hop that was refused, retried or lost, not only on one that landed#306
davidmckayv merged 3 commits into
CopilotKit:mainfrom
kevin9327:fix/handoff-refusal-names-bot

Conversation

@kevin9327

Copy link
Copy Markdown
Contributor

What this changes

The Audit screen's Bot column reads payload.bot and nothing else
(app/src/routes/_authed/admin/audit.tsx:259-264), rendering - when it is absent.
agent.handoff_offered explains why that matters, in the comment added when it was given the key
(server/src/agents/handoff.ts:386-389):

The Bot that did this, under the key the Audit screen reads for its Bot column. from below says
the same thing and is what the payload is read by, but the screen renders payload.bot and nothing
else, so without this the two handoff rows are the only Bot actions on a screen headed "Every
action a Bot took" that name no Bot.

agent.handoff_delivered got it too (handoff-runner.ts:358-360, "See the same key on
agent.handoff_offered"
). The four rows either side of that pair did not:

event type written at payload.bot Bot column
agent.handoff_offered handoff.ts:390 yes named
agent.handoff_delivered handoff-runner.ts:360 yes named
agent.handoff_refused handoff.ts:112-120 no -
agent.handoff_retried (second attempt) handoff-runner.ts:268-274 no -
agent.handoff_retried (lease gone elsewhere) handoff-runner.ts:342-348 no -
agent.handoff_failed handoff-runner.ts:408-415 no -

So the screen names the Bot on the one outcome you can already see elsewhere, and draws a dash on the
three you cannot. server/src/audit.ts:341-347 says this of the first of them:

BOTH OUTCOMES, and the refused one is the more important of the pair. A hop that happened is
visible in the transcript anyway; a hop that was refused is invisible everywhere else.

The same holds for agent.handoff_failed, which is where a person's unanswered question ends.

agent.escalated / agent.escalation_failed one file over share a single payload carrying bot
(server/src/agents/escalation.ts:119-135), so escalation is right on both outcomes. That is the
shape this restores — four lines, no other change.

Pairs with #302, which stops the same screen labelling agent.handoff_refused as Allowed.
Together, a refused hop reads as a refusal and says whose it was. Either alone leaves half the row
wrong. They touch different halves of the repo and merge independently (only the CHANGELOG.md
anchor collides).

Where it runs

  • New state that outlives a request? None. Four extra keys on payloads already being written.
  • What happens on the second replica? The same. work.fromBotId comes off the queued row
    every replica reads, and from.botId off the signed run assertion, so any replica delivering
    the hop writes the same value.
  • Anything serialised? Nothing new — audit_events is append-only and these are the same
    inserts with one more key.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? None.

Boundary and audit

  • Every acting call still goes through the gateway: unchanged.
  • New refusals and new failures each write a row — the rows already existed; this makes them
    attributable, which is what an audit row is for.
  • Nothing new is trusted from the client. Both values are the deployment's own: from.botId
    comes from the signed run assertion and work.fromBotId from the row this deployment wrote,
    never from anything a model produced.

Changelog

  • CHANGELOG.md, under Unreleased.

Proof

Three assertions, in the two files that already assert this for the accepted pair:

  • server/tests/agent-handoff.test.ts — the existing "both outcomes leave a row naming the run and
    the reason"
    now asserts bot on the refused half as well as the accepted one, and a new case
    walks all five refusal reasons (no_task, not_granted, unknown_bot, depth cap, fan-out
    cap).
  • server/tests/agent-handoff-runner.test.ts — a sibling to the existing "a delivery names the Bot
    that handed the work over"
    , covering agent.handoff_retried and agent.handoff_failed.

Against main's handoff.ts and handoff-runner.ts with the new assertions in place:

error: expect(received).toMatchObject(expected)
(fail) delivering a hop > a hop that was retried or that failed names the Bot too
(fail) handing work to another Bot > both outcomes leave a row naming the run and the reason
(fail) handing work to another Bot > every refusal names the Bot that was refused
 32 pass, 3 fail

With the change:

bun test server/tests/agent-handoff.test.ts server/tests/agent-handoff-runner.test.ts \
         server/tests/agent-handoff-delivery.test.ts server/tests/agent-handoff-tool.test.ts
                       -> 61 pass, 0 fail (128 expect() calls)
bun run format:check   -> Checked 487 files. No fixes applied.
bun run lint           -> Checked 490 files. No fixes applied.
bun run typecheck      -> app / server / worker all exit 0

…one that landed

The Audit screen's Bot column reads `payload.bot` and nothing else, and renders a dash without it.
`agent.handoff_offered` says so in its own comment, added when the accepted pair was fixed:

    without this the two handoff rows are the only Bot actions on a screen headed "Every action a
    Bot took" that name no Bot.

The pair got the key. The four rows either side of it did not: `agent.handoff_refused`, both
`agent.handoff_retried` rows, and `agent.handoff_failed` carry `from` and no `bot`, so every one of
them draws a dash.

Those are the rows the trail exists for. `server/src/audit.ts` says it outright of the first one —
"a hop that happened is visible in the transcript anyway; a hop that was refused is invisible
everywhere else" — and the same holds for one that ran out of attempts, which is where a person's
unanswered question ends. So the screen named the Bot on the outcome you could already see, and drew
a dash on the three you could not.

`agent.escalated` and `agent.escalation_failed` one file over share a single payload and get this
right on both outcomes, which is the shape being restored here.

Pairs with CopilotKit#302, which stops that same screen calling `agent.handoff_refused` "Allowed". Together a
refused hop reads as a refusal and says whose it was; either alone leaves half the row wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

davidmckayv
davidmckayv previously approved these changes Aug 31, 2026

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified + CI verify green; template-appropriate correctness fix.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI verify green pre-rebase; CHANGELOG-only rebase; template-appropriate.

@davidmckayv
davidmckayv merged commit b85f361 into CopilotKit:main Aug 31, 2026
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.

2 participants