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:
co-authored by
Claude Fable 5
parent
44f552cc3d
commit
31f9a4d5bf
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user