fix(13-08): declined actions never re-prompt + timeout chain covers the human wait

Two more on-device UAT findings:

1. Deny-retry loop: the model, told 'the user declined', simply called the
   tool again — each retry minted a fresh pending and re-opened the dialog
   (T-13-50 habituation, mechanized). ToolExecCtx now remembers declined
   actions for the turn, keyed by confirm::action_key — the same canonical
   (tool_name, validated_args) identity the nonce binds — and execute_tool
   refuses a re-ask before the gate, minting nothing. Regression test
   declined_action_never_reprompts_same_turn.

2. Timeout chain: rpcClient's 15s default aborted every confirmable turn
   client-side while the node kept the pending alive — the next turn then
   re-announced it (modal over and over) and every wait read as 'timed
   out'. assistant.chat now rides a 420s timeout; AIUI's bridge goes
   180s→430s so the host's error path (which also expires the dialog)
   always fires first. Declined ToolResult text now also tells the model
   to stop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-05 14:50:17 -04:00
co-authored by Claude Fable 5
parent 44c864ac14
commit 2d1f09d8c8
5 changed files with 118 additions and 5 deletions
+9
View File
@@ -202,9 +202,18 @@ export class ContextBroker {
// trusted chrome can draw the dialog (see handleToolConfirmRequest).
this.beginConfirmPolling()
try {
// 13-08 UAT: a chat turn legitimately spans multiple model round
// trips (ASSISTANT_HTTP_TIMEOUT 180s each) plus a human-speed
// confirm-gate wait (CONFIRM_TIMEOUT 300s). The rpcClient default
// (15s) aborted every confirmable turn client-side while the node
// kept the pending confirmation alive — the modal then re-announced
// on the next turn, over and over. 420s must stay below AIUI's
// bridge timeout (430s) so this error, not the bridge's, is the one
// the user sees.
const result = await rpcClient.call<{ text: string }>({
method: 'assistant.chat',
params: { text },
timeout: 420_000,
})
this.postToIframe({
type: 'chat:response',