From ea5d40fbfe3ff33135442ceb574d184b44421380 Mon Sep 17 00:00:00 2001 From: Amin Vakil Date: Tue, 11 Aug 2026 23:40:24 +0330 Subject: [PATCH] fix(pi): ignore replayed glance images --- pi/glance.test.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++- pi/glance.ts | 10 +++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/pi/glance.test.ts b/pi/glance.test.ts index dca3560..2d01801 100644 --- a/pi/glance.test.ts +++ b/pi/glance.test.ts @@ -220,7 +220,7 @@ describe("pi/glance", () => { expect(__testing.isSessionStale()).toBe(true); }); - it("parses image SSE events and stops listening after the first image", async () => { + it("parses image SSE events and stops listening after the first new image", async () => { const session = { id: "session-2", url: "https://glance.sh/s/session-2", @@ -259,6 +259,47 @@ describe("pi/glance", () => { expect(__testing.getState().currentSession).toEqual(session); }); + it("retries a replayed image when delivery previously failed", async () => { + const session = { + id: "session-retry", + url: "https://glance.sh/s/session-retry", + } satisfies SessionResponse; + const image = { + url: "https://cdn.glance.sh/image-retry.png", + expiresAt: 123, + } satisfies ImageEvent; + + __testing.setSession(session); + vi.stubGlobal("fetch", vi.fn(async () => + sseResponse([ + `event: image\ndata: ${JSON.stringify(image)}\n\n`, + ]), + )); + + const onImage = vi.fn() + .mockImplementationOnce(() => { + throw new Error("delivery failed"); + }); + + await expect( + __testing.listenForImages( + session.id, + new AbortController().signal, + onImage, + ), + ).rejects.toThrow("delivery failed"); + + await __testing.listenForImages( + session.id, + new AbortController().signal, + onImage, + ); + + expect(onImage).toHaveBeenCalledTimes(2); + expect(onImage).toHaveBeenNthCalledWith(1, image); + expect(onImage).toHaveBeenNthCalledWith(2, image); + }); + it("does not start the background listener on session_start", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -375,6 +416,59 @@ describe("pi/glance", () => { }); }); + it("reuses the session while ignoring images replayed by SSE reconnects", async () => { + let sessionCalls = 0; + let eventCalls = 0; + + const fetchMock = vi.fn((input: string | URL) => { + const url = String(input); + + if (url === "https://glance.sh/api/session") { + sessionCalls += 1; + return Promise.resolve(jsonResponse({ + id: "session-reused", + url: "/s/session-reused", + })); + } + + if (url === "https://glance.sh/api/session/session-reused/events") { + eventCalls += 1; + return Promise.resolve(sseResponse( + Array.from({ length: eventCalls }, (_, index) => + `event: image\ndata: {"url":"https://cdn.glance.sh/image-${index + 1}.png","expiresAt":123}\n\n`, + ), + )); + } + + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const pi = createPi(); + glanceExtension(pi.api as never); + + const command = pi.commands.get("glance")!; + const ctx = createCommandContext(); + + for (let imageNumber = 1; imageNumber <= 3; imageNumber += 1) { + await command.handler([], ctx); + await vi.waitFor(() => { + expect(pi.api.sendUserMessage).toHaveBeenCalledTimes(imageNumber); + expect(__testing.getState().running).toBe(false); + }); + } + + expect(sessionCalls).toBe(1); + expect(eventCalls).toBe(3); + for (let imageNumber = 1; imageNumber <= 3; imageNumber += 1) { + expect(pi.api.sendUserMessage).toHaveBeenNthCalledWith( + imageNumber, + `Screenshot: https://cdn.glance.sh/image-${imageNumber}.png`, + { deliverAs: "followUp" }, + ); + } + }); + it("waits for the next image in the glance tool and returns its URL", async () => { const session = { id: "session-5", diff --git a/pi/glance.ts b/pi/glance.ts index 7d5d690..1c75fab 100644 --- a/pi/glance.ts +++ b/pi/glance.ts @@ -64,6 +64,7 @@ let sessionCreatedAt = 0; let abortController: AbortController | null = null; let running = false; let waiterCounter = 0; +const seenImageUrls = new Set(); async function createSession(): Promise { const res = await fetch(`${BASE_URL}/api/session`, { @@ -75,6 +76,7 @@ async function createSession(): Promise { session.url = normalizeSessionUrl(session.url); currentSession = session; sessionCreatedAt = Date.now(); + seenImageUrls.clear(); return session; } @@ -139,6 +141,7 @@ function stopBackground() { } abortController = null; currentSession = null; + seenImageUrls.clear(); } function sleep(ms: number): Promise { @@ -206,13 +209,19 @@ async function listenForImages( } else if (line === "") { if (eventType === "image" && dataLines.length > 0) { const data = JSON.parse(dataLines.join("\n")) as ImageEvent; + eventType = ""; + dataLines = []; + if (seenImageUrls.has(data.url)) continue; + onImage(data); + seenImageUrls.add(data.url); clearTimeout(timeout); return; } if (eventType === "expired") { // Session gone — force refresh on next loop iteration currentSession = null; + seenImageUrls.clear(); clearTimeout(timeout); return; } @@ -307,6 +316,7 @@ export const __testing = { setSession(session: SessionResponse | null, createdAt = Date.now()) { currentSession = session; sessionCreatedAt = session ? createdAt : 0; + seenImageUrls.clear(); }, stopBackground, waitForNextImage,