Skip to content

fix(tui): answer a tool approval prompt only once - #1349

Merged
will-lamerton merged 3 commits into
Nano-Collective:mainfrom
addyCooks:fix/confirmation-answer-once
Sep 21, 2026
Merged

will-lamerton merged 3 commits into
Nano-Collective:mainfrom
addyCooks:fix/confirmation-answer-once

Conversation

@addyCooks

Copy link
Copy Markdown
Contributor

Follow-up to #1159,
from @will-lamerton's review note on the merged PR.

Description

The approval queue resolves its head on every answer (queueRef.current.shift()?.resolve(result)), and nothing ties a result to the request that was on screen. So a second answer from an already-settled prompt consumed the next queued request and resolved it with the previous answer, without that request ever being shown approving a tool the user was never asked about. Before #1159 the single-resolver code nulled the resolver, so a repeat answer was a no-op.

It is not reachable today: the key={toolCall.id} props added in #1159 unmount the prompt as the queue advances. That is the problem the queue's safety depends on two key props in a different file, with nothing in the code saying so.

ToolConfirmation now answers at most once per request, covering all three paths into the slot (the select prompt, Escape, and the formatter-crash auto-cancel), and resets when a new toolCall arrives so a reused instance stays answerable instead of being silently blocked. This mirrors the answeredRef + previousQuestion guard in question-prompt.tsx the reason the question slot was already safe.

One deliberate deviation: the review suggested the guard live in useHandlerQueue. I put it in the component instead, because the hook's answer callbacks receive only a result with no request identity, so the hook cannot distinguish a repeat answer for request A from a legitimate answer for B. The component is where both double-fire windows actually are. Happy to move it if you would rather change the callback signature to carry identity.

26 lines of source across one component, plus tests and a changeset.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Changeset

  • Added a changeset (pnpm changeset) describing this change for the changelog

Testing

Automated Tests

  • New features include passing tests in .spec.ts/tsx files
  • All existing tests pass (pnpm test:all completes successfully)
  • Tests cover both success and error scenarios

Manual Testing

  • Tested with Ollama
  • Tested with OpenRouter
  • Tested with OpenAI-compatible API
  • Tested MCP integration (if applicable)

Checklist

  • If this was for an open issue, I was assigned to it
  • Code follows project style guidelines
  • Self-review completed
  • Documentation updated (if needed), no user-facing behaviour change, so none needed
  • No breaking changes (or clearly documented)
  • Appropriate logging added using structured logging, none needed; the guard is a silent early return

No issue for this one: it came from your review note on #1159.
Happy to file one if you would prefer it tracked.

The approval queue resolves its head on every answer, so a second answer from an already-settled prompt consumed the next queued request and resolved it unseen - approving a tool the user was never shown. ToolConfirmation now answers at most once per request across all three paths into the slot (select, Escape, formatter-crash auto-cancel), and resets when a new toolCall arrives so a reused instance stays answerable. Mirrors the answeredRef guard in question-prompt.tsx. Follow-up to Nano-Collective#1159.
@github-actions

Copy link
Copy Markdown
Contributor

nc-review: nothing to raise

@addyCooks — nothing to raise from the automated review.

Adds a one-answer-per-request guard in ToolConfirmation that mirrors the established answeredRef + previousQuestion pattern in question-prompt.tsx. The guard covers all three settle paths (select prompt, Escape, formatter-crash auto-cancel) and resets via reference comparison when a new toolCall arrives, so the component stays correct even if the key={toolCall.id} prop is dropped later. Both tests genuinely exercise the new behaviour and would fail without the guard. Changeset package name resolves against the workspace.


🔴 blocking · 🟠 a reviewer would ask for a change · ⚪ optional

Automated code review — correctness, security, design, tests, plus duplicates and scope. A human still decides; this is not a substitute for review and is not exhaustive. The required status checks separately cover lint, formatting, types, unused dependencies, the test suite and the build. This bot never merges. Maintainers can rerun with /re-review.

@github-actions github-actions Bot added the agent:clean nc-review had nothing to raise label Sep 16, 2026
The coverage gate failed on a 0.01% drop. Two things were behind it, and both
are in the tests rather than the guard.

The reset test mounted a Harness with an `onCancel` it never called: it
answered both requests with Enter, so the callback body was dead. It now
answers the reused instance with Escape instead, which is the better test as
well as the covered one — Escape is the path that was double-firing, so
answering through it proves the reset re-opens every answer route rather than
only the one the first request happened to use.

The per-tool validator's catch had no test at all. A validator that throws now
has one, and it pins the distinction the guard depends on: a formatter crash
auto-cancels, a validation error must not, so the prompt stays up and the
single allowed answer is still the user's to spend.

tool-confirmation.tsx goes from 17 uncovered lines to 10 and from 92.58% to
96.02%; the spec is fully covered. Together the two files move from 442/459 to
562/572, so the branch now adds headroom rather than nudging the gate.
@will-lamerton
will-lamerton merged commit f2c7bdb into Nano-Collective:main Sep 21, 2026
15 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:clean nc-review had nothing to raise area:tui Terminal UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants