From 31f9a4d5bfbcd7ead6874a634cba10baa984254e Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 13:56:49 -0400 Subject: [PATCH] =?UTF-8?q?fix(13-08):=20confirm=20timeout=20120s=E2=86=92?= =?UTF-8?q?300s=20+=20chrome=20closes=20an=20expired=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/archipelago/src/assistant/confirm.rs | 9 +++- .../services/__tests__/toolConfirm.test.ts | 50 +++++++++++++++++++ neode-ui/src/services/contextBroker.ts | 20 ++++++++ neode-ui/src/views/Chat.vue | 14 ++++++ 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/core/archipelago/src/assistant/confirm.rs b/core/archipelago/src/assistant/confirm.rs index 9da381a5..fc56b4a8 100644 --- a/core/archipelago/src/assistant/confirm.rs +++ b/core/archipelago/src/assistant/confirm.rs @@ -33,8 +33,13 @@ use super::tools::{ToolArgs, ToolDef}; /// How long an unresolved confirmation waits before declining on its own. /// Human-speed (the operator may be reading carefully), but bounded — an -/// abandoned dialog must never leak its waiting task (T-13-51). -pub const CONFIRM_TIMEOUT: Duration = Duration::from_secs(120); +/// abandoned dialog must never leak its waiting task (T-13-51). 120s +/// proved too short in 13-08's on-device UAT: a real operator reading the +/// dialog (and screenshotting it, per the checkpoint script) was timed out +/// mid-decision, and their Approve then landed on a dead entry. Five +/// minutes keeps the bound while making that race an edge case; the +/// chrome now also closes the dialog when its pending action expires. +pub const CONFIRM_TIMEOUT: Duration = Duration::from_secs(300); /// The human's answer, as seen by the suspended tool call. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/neode-ui/src/services/__tests__/toolConfirm.test.ts b/neode-ui/src/services/__tests__/toolConfirm.test.ts index b1cc5700..d69f6d27 100644 --- a/neode-ui/src/services/__tests__/toolConfirm.test.ts +++ b/neode-ui/src/services/__tests__/toolConfirm.test.ts @@ -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((resolve) => { + releaseChat = resolve + }) as Promise + } + if (opts.method === 'assistant.pending') { + return Promise.resolve({ pending: currentPending }) as Promise + } + return Promise.resolve({}) as Promise + }) + 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() diff --git a/neode-ui/src/services/contextBroker.ts b/neode-ui/src/services/contextBroker.ts index 96aad24b..0ad92144 100644 --- a/neode-ui/src/services/contextBroker.ts +++ b/neode-ui/src/services/contextBroker.ts @@ -237,6 +237,10 @@ export class ContextBroker { if (this.activeChatTurns === 0 && this.confirmPollTimer) { clearInterval(this.confirmPollTimer) this.confirmPollTimer = null + // The turn is over, so any confirmation still on screen is dead — + // either it was resolved (dialog already closed) or the node's + // timeout declined it while the human was still reading. + this.expireStaleConfirms() } } @@ -245,6 +249,7 @@ export class ContextBroker { const res = await rpcClient.call<{ pending: PendingToolConfirm | null }>({ method: 'assistant.pending', }) + this.expireStaleConfirms(res?.pending?.req_id) if (res?.pending) this.handleToolConfirmRequest(res.pending) } catch { // Transient RPC failure — the next poll retries; the node's own @@ -252,6 +257,21 @@ export class ContextBroker { } } + /** 13-08 on-device UAT: when a pending confirmation vanishes node-side + * (timed out, or resolved from another session) while the trusted chrome + * still shows its dialog, tell the chrome to close it. An approval + * clicked after expiry can only be refused by the node — leaving the + * dialog up invites exactly that dead click. Same host-only CustomEvent + * discipline as the request/response pair: the iframe has no path to + * dispatch or observe this. */ + private expireStaleConfirms(currentReqId?: string) { + for (const id of [...this.announcedConfirmReqIds]) { + if (id === currentReqId) continue + this.announcedConfirmReqIds.delete(id) + window.dispatchEvent(new CustomEvent('aiui:tool-confirm-expired', { detail: { reqId: id } })) + } + } + /** * 13-08 (D-07/D-11): announce one node-reported pending confirmation to * the trusted chrome, and arm a one-shot listener for the host's answer. diff --git a/neode-ui/src/views/Chat.vue b/neode-ui/src/views/Chat.vue index 64553e18..c7e16d03 100644 --- a/neode-ui/src/views/Chat.vue +++ b/neode-ui/src/views/Chat.vue @@ -217,6 +217,16 @@ function dismissToolConfirm() { toolConfirm.value = null } +function onToolConfirmExpired(e: Event) { + // 13-08 on-device UAT: the node no longer holds this pending action + // (timed out, or resolved elsewhere) — close the dialog rather than + // leave the human an Approve button whose click can only be refused. + const detail = (e as CustomEvent).detail as { reqId?: string } + if (toolConfirm.value && detail?.reqId === toolConfirm.value.reqId) { + toolConfirm.value = null + } +} + function onAiuiMessage(event: MessageEvent) { if (!aiuiUrl.value) return // Validate origin — only accept messages from AIUI @@ -246,6 +256,8 @@ function armChatLive() { window.addEventListener('message', onAiuiMessage) window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest) window.addEventListener('aiui:tool-confirm-request', onToolConfirmRequest) + window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired) + window.addEventListener('aiui:tool-confirm-expired', onToolConfirmExpired) broker?.stop() broker = null if (aiuiUrl.value) { @@ -266,6 +278,7 @@ onActivated(() => armChatLive()) onDeactivated(() => { window.removeEventListener('message', onAiuiMessage) window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest) + window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired) broker?.stop() broker = null if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null } @@ -280,6 +293,7 @@ onMounted(() => armChatLive()) onBeforeUnmount(() => { window.removeEventListener('message', onAiuiMessage) window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest) + window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired) broker?.stop() broker = null if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }