Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion pi/glance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions pi/glance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ let sessionCreatedAt = 0;
let abortController: AbortController | null = null;
let running = false;
let waiterCounter = 0;
const seenImageUrls = new Set<string>();

async function createSession(): Promise<SessionResponse> {
const res = await fetch(`${BASE_URL}/api/session`, {
Expand All @@ -75,6 +76,7 @@ async function createSession(): Promise<SessionResponse> {
session.url = normalizeSessionUrl(session.url);
currentSession = session;
sessionCreatedAt = Date.now();
seenImageUrls.clear();
return session;
}

Expand Down Expand Up @@ -139,6 +141,7 @@ function stopBackground() {
}
abortController = null;
currentSession = null;
seenImageUrls.clear();
}

function sleep(ms: number): Promise<void> {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -307,6 +316,7 @@ export const __testing = {
setSession(session: SessionResponse | null, createdAt = Date.now()) {
currentSession = session;
sessionCreatedAt = session ? createdAt : 0;
seenImageUrls.clear();
},
stopBackground,
waitForNextImage,
Expand Down