fix(13-08): confirm timeout 120s→300s + chrome closes an expired dialog

On-device UAT: the operator was timed out mid-read (120s), the chat turn
returned 'declined' while the dialog was still up, and their Approve then
hit a dead entry ('no such pending confirmation', 13:37:12 log). Nothing
executed — the gate failed safe — but the UX was a lie in both directions.

- CONFIRM_TIMEOUT 120s→300s: human-speed per T-13-51's own rubric.
- ContextBroker dispatches aiui:tool-confirm-expired when a pending action
  vanishes node-side (poll) or the turn ends; Chat.vue closes the modal on
  it. Same host-only CustomEvent discipline; iframe has no path to it.
- Two new tests; 21/21 green across toolConfirm + chatAiuiEmbed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-05 13:56:49 -04:00
co-authored by Claude Fable 5
parent 44f552cc3d
commit 31f9a4d5bf
4 changed files with 91 additions and 2 deletions
@@ -112,6 +112,56 @@ describe('tool confirmation — ContextBroker half', () => {
await chat
})
it('a confirmation that vanishes node-side is expired to the chrome so the dialog closes', async () => {
let currentPending: typeof PENDING_IMMICH | null = PENDING_IMMICH
let releaseChat: (v: unknown) => void = () => {}
vi.mocked(rpcClient.call).mockImplementation((opts: { method: string }) => {
if (opts.method === 'assistant.chat') {
return new Promise<unknown>((resolve) => {
releaseChat = resolve
}) as Promise<never>
}
if (opts.method === 'assistant.pending') {
return Promise.resolve({ pending: currentPending }) as Promise<never>
}
return Promise.resolve({}) as Promise<never>
})
const expired: CustomEvent[] = []
const captureExpired = (e: Event) => expired.push(e as CustomEvent)
window.addEventListener('aiui:tool-confirm-expired', captureExpired)
const chat = startChat()
await vi.advanceTimersByTimeAsync(2000)
expect(confirmRequests).toHaveLength(1)
// The node times the confirmation out: pending goes null mid-turn.
currentPending = null
await vi.advanceTimersByTimeAsync(2000)
expect(expired).toHaveLength(1)
expect(expired[0]!.detail.reqId).toBe('confirm-1')
window.removeEventListener('aiui:tool-confirm-expired', captureExpired)
releaseChat({ text: 'done' })
await chat
})
it('the chat turn ending expires any confirmation still on screen', async () => {
const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH)
const expired: CustomEvent[] = []
const captureExpired = (e: Event) => expired.push(e as CustomEvent)
window.addEventListener('aiui:tool-confirm-expired', captureExpired)
const chat = startChat()
await vi.advanceTimersByTimeAsync(2000)
expect(confirmRequests).toHaveLength(1)
releaseChat()
await chat
expect(expired).toHaveLength(1)
expect(expired[0]!.detail.reqId).toBe('confirm-1')
window.removeEventListener('aiui:tool-confirm-expired', captureExpired)
})
it('approving calls assistant.confirm-tool over the page RPC session carrying the node-minted nonce', async () => {
const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH)
const chat = startChat()