From 78e4d32a5252ec7711adb8dd63c6e42482be9bdc Mon Sep 17 00:00:00 2001 From: dickhardt Date: Fri, 14 Aug 2026 18:58:10 +0100 Subject: [PATCH] agent 3.0.2: pollDeferred notifies onInteraction only when (url, code) changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PS can carry the interaction on every 202 while it applies — Wallet's reach fallback re-advertises the url each ~5s poll until the record resolves — and onInteraction typically opens a browser, so per-poll notification re-opened the page every cycle (#17). Dedupe by (url, code): notify on first sight and again only if the advertised pair changes. The initial url/code passed into pollDeferred seeds the dedupe, so the same pair arriving via AAuth-Requirement is not a second notification. 18 tests passing (3 new). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9VG2CXgAaQcNAPWrCy6Yd --- agent/package.json | 2 +- agent/src/deferred.test.ts | 78 +++++++++++++++++++++++++++++++++++++ agent/src/deferred.ts | Bin 8202 -> 8652 bytes 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/agent/package.json b/agent/package.json index 67d9a05..ece02ac 100644 --- a/agent/package.json +++ b/agent/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/agent", - "version": "3.0.1", + "version": "3.0.2", "description": "Agent-side AAuth protocol library — HTTP Signatures, person tokens, token exchange, deferred polling", "type": "module", "exports": { diff --git a/agent/src/deferred.test.ts b/agent/src/deferred.test.ts index 542ddad..ae81791 100644 --- a/agent/src/deferred.test.ts +++ b/agent/src/deferred.test.ts @@ -204,6 +204,84 @@ describe('pollDeferred', () => { expect(onInteraction).toHaveBeenCalledWith('https://auth.example/interact', 'WXYZ5678') }) + it('dedupes onInteraction across polls re-advertising the same url and code', async () => { + // A PS re-advertises the interaction on every poll while it applies + // (e.g. Wallet's reach fallback) — the agent must not re-notify (and + // typically re-open a browser) per poll cycle (#17). + const onInteraction = vi.fn() + const readvertised = () => + new Response(null, { + status: 202, + headers: { + 'aauth-requirement': 'requirement=interaction; url="https://auth.example/interact"; code="WXYZ5678"', + 'Retry-After': '1', + }, + }) + mockFetch + .mockResolvedValueOnce(readvertised()) + .mockResolvedValueOnce(readvertised()) + .mockResolvedValueOnce(readvertised()) + .mockResolvedValueOnce(new Response('ok', { status: 200 })) + + await pollDeferred({ + signedFetch: mockFetch, + locationUrl: 'https://auth.example/pending/123', + onInteraction, + }) + + expect(onInteraction).toHaveBeenCalledTimes(1) + }) + + it('notifies again when the advertised interaction code changes', async () => { + const onInteraction = vi.fn() + const withCode = (code: string) => + new Response(null, { + status: 202, + headers: { + 'aauth-requirement': `requirement=interaction; url="https://auth.example/interact"; code="${code}"`, + 'Retry-After': '1', + }, + }) + mockFetch + .mockResolvedValueOnce(withCode('WXYZ5678')) + .mockResolvedValueOnce(withCode('ABCD1234')) + .mockResolvedValueOnce(new Response('ok', { status: 200 })) + + await pollDeferred({ + signedFetch: mockFetch, + locationUrl: 'https://auth.example/pending/123', + onInteraction, + }) + + expect(onInteraction).toHaveBeenCalledTimes(2) + expect(onInteraction).toHaveBeenNthCalledWith(1, 'https://auth.example/interact', 'WXYZ5678') + expect(onInteraction).toHaveBeenNthCalledWith(2, 'https://auth.example/interact', 'ABCD1234') + }) + + it('does not re-notify from the header for the initial interaction it was given', async () => { + // The initial url/code passed into pollDeferred and the same pair + // arriving via AAuth-Requirement are one interaction, not two. + const onInteraction = vi.fn() + const pending = new Response(null, { + status: 202, + headers: { + 'aauth-requirement': 'requirement=interaction; url="https://auth.example/interact"; code="ABCD1234"', + 'Retry-After': '1', + }, + }) + mockFetch.mockResolvedValueOnce(pending).mockResolvedValueOnce(new Response('ok', { status: 200 })) + + await pollDeferred({ + signedFetch: mockFetch, + locationUrl: 'https://auth.example/pending/123', + interactionUrl: 'https://auth.example/interact', + interactionCode: 'ABCD1234', + onInteraction, + }) + + expect(onInteraction).toHaveBeenCalledTimes(1) + }) + it('handles clarification flow', async () => { const onClarification = vi.fn().mockResolvedValue('42 widgets') const clarificationResponse = new Response( diff --git a/agent/src/deferred.ts b/agent/src/deferred.ts index a503a7da7dd51933319653b4dab08d17d7becb43..1e5439203b0b339cc8851f9a6f3a78dffb611512 100644 GIT binary patch delta 623 zcmYjOO^Opi6cz-*jtGK?3l|?ai!{WH&PEvM!C?sMM#K@pjZ97T%XBf7dPP-rLX!z` zAuim;19$|%Yj_H8;0=7;N#j5_UB&nJ-q+t={66_P&(_CydkU?bydgA56~VYfLFr`J zL7vHn$-9p@czS?EWvsvn%6n@lVo2kQF;oWZ?<0`X6{gDCbEPlk`b3p;3duxaw!U?+ z=s2FOMeUNYShqkR&7tgFBHKB|9DhE4m-yG$hDnV#TKpTZCl{e`9(WNcj1U=)lbH(S zY_qjmROGtgp(Kx>xuhIknShg5$8TOvPDgN$IKfjuH`4I&v*-C3?6*-~%VtY`5we&qOF05+PW-vu?DdvA~u@*v@OHJVZ>v zxC>mt)j}$|kB~IGnAW?!uJ=%&-Kf`XH|uXXiX$vVz0>288nHavTZ&|LV{h5kusZ7q zW{P1y1&X3*xw-s}R0r1)1RQiTii2RAdv_j6C;3f@+#C%?cmMY)^wowlzSa3Pzdj9q MWc~TegRi&$0kp5rA^-pY delta 132 zcmX@(+~u&LjBBzHZ^qA6q55(Qc*;mCx7J8 z*}R5#3fE*aVX!VkD~05Y#GIVey!2GPQiLXiAjlAfT1_qmD46_%M|E?ML=`6hOxG