Skip to content
Merged
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
39 changes: 39 additions & 0 deletions opencode/glance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ function cleanupWaiters() {
}
}

function getOpenCodeWaiterKeys(): string[] {
return Object.keys(globalThis).filter((key) =>
key.startsWith("__glance_waiter_opencode_"),
)
}

/**
* URL-aware fetch mock. Routes by URL so both the background loop and
* tool calls get correct responses regardless of call order.
Expand Down Expand Up @@ -216,6 +222,39 @@ describe("opencode glance plugin", () => {
expect(result).toContain("https://glance.sh/chunked.png")
})

it("registers distinct waiters even within the same millisecond", async () => {
vi.stubGlobal(
"fetch",
routedFetch({
session: { id: "sess-waiters", url: "/s/sess-waiters" },
}),
)

const GlancePlugin = await loadPlugin()
const plugin = await GlancePlugin(mockClient())

await plugin.tool.glance.execute({})

const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(123)
const ac1 = new AbortController()
const ac2 = new AbortController()

const wait1 = plugin.tool.glance_wait.execute({}, mockContext(ac1.signal))
const wait2 = plugin.tool.glance_wait.execute({}, mockContext(ac2.signal))

expect(getOpenCodeWaiterKeys()).toHaveLength(2)

ac1.abort()
ac2.abort()

await expect(Promise.all([wait1, wait2])).resolves.toEqual([
"Session timed out. Ask the user to paste an image at https://glance.sh/s/sess-waiters",
"Session timed out. Ask the user to paste an image at https://glance.sh/s/sess-waiters",
])

dateNowSpy.mockRestore()
})

it("returns timeout message when aborted", async () => {
vi.stubGlobal(
"fetch",
Expand Down
12 changes: 10 additions & 2 deletions opencode/glance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const RECONNECT_DELAY_MS = 3_000
/** How often to create a fresh session (sessions have 10-min TTL). */
const SESSION_REFRESH_MS = 8 * 60 * 1000

const WAITER_PREFIX = "__glance_waiter_opencode_"

interface SessionResponse {
id: string
url: string
Expand All @@ -43,6 +45,7 @@ let currentSession: SessionResponse | null = null
let sessionCreatedAt = 0
let abortController: AbortController | null = null
let running = false
let waiterCounter = 0

async function createSession(): Promise<SessionResponse> {
const res = await fetch(`${BASE_URL}/api/session`, { method: "POST" })
Expand Down Expand Up @@ -101,6 +104,11 @@ function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}

function nextWaiterKey(): string {
waiterCounter += 1
return `${WAITER_PREFIX}${Date.now()}_${waiterCounter}`
}

// ── SSE listener (multi-image) ─────────────────────────────────────

async function listenForImages(
Expand Down Expand Up @@ -183,7 +191,7 @@ function waitForNextImage(signal?: AbortSignal): Promise<ImageEvent | null> {

const timeout = setTimeout(() => resolve(null), SSE_TIMEOUT_MS)

const key = `__glance_waiter_${Date.now()}`
const key = nextWaiterKey()
;(globalThis as any)[key] = (image: ImageEvent) => {
clearTimeout(timeout)
delete (globalThis as any)[key]
Expand All @@ -200,7 +208,7 @@ function waitForNextImage(signal?: AbortSignal): Promise<ImageEvent | null> {

function dispatchToWaiters(image: ImageEvent) {
for (const key of Object.keys(globalThis)) {
if (key.startsWith("__glance_waiter_")) {
if (key.startsWith(WAITER_PREFIX)) {
const fn = (globalThis as any)[key]
if (typeof fn === "function") fn(image)
}
Expand Down
30 changes: 30 additions & 0 deletions pi/glance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ function createTheme(): Theme {
};
}

function getPiWaiterKeys(): string[] {
return Object.keys(globalThis).filter((key) =>
key.startsWith("__glance_waiter_pi_"),
);
}

function createPi(options?: { autoShutdownOnMessage?: boolean }) {
const events = new Map<string, (...args: any[]) => unknown>();
const commands = new Map<string, CommandDefinition>();
Expand Down Expand Up @@ -293,6 +299,30 @@ describe("pi/glance", () => {
});
});

it("registers distinct waiters even within the same millisecond", async () => {
__testing.setSession({
id: "session-waiters",
url: "https://glance.sh/s/session-waiters",
});

const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(123);

const ac1 = new AbortController();
const ac2 = new AbortController();

const wait1 = __testing.waitForNextImage(ac1.signal);
const wait2 = __testing.waitForNextImage(ac2.signal);

expect(getPiWaiterKeys()).toHaveLength(2);

ac1.abort();
ac2.abort();

await expect(Promise.all([wait1, wait2])).resolves.toEqual([null, null]);

dateNowSpy.mockRestore();
});

it("shows the active session URL through the /glance command", async () => {
const session = {
id: "session-4",
Expand Down
13 changes: 11 additions & 2 deletions pi/glance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const RECONNECT_DELAY_MS = 3_000;
/** How often to create a fresh session (sessions have 10-min TTL). */
const SESSION_REFRESH_MS = 8 * 60 * 1000; // 8 minutes — well before expiry

const WAITER_PREFIX = "__glance_waiter_pi_";

interface SessionResponse {
id: string;
url: string;
Expand Down Expand Up @@ -56,6 +58,7 @@ let currentSession: SessionResponse | null = null;
let sessionCreatedAt = 0;
let abortController: AbortController | null = null;
let running = false;
let waiterCounter = 0;

async function createSession(): Promise<SessionResponse> {
const res = await fetch(`${BASE_URL}/api/session`, { method: "POST" });
Expand Down Expand Up @@ -128,6 +131,11 @@ function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

function nextWaiterKey(): string {
waiterCounter += 1;
return `${WAITER_PREFIX}${Date.now()}_${waiterCounter}`;
}

// ── SSE listener (multi-image) ─────────────────────────────────────

/**
Expand Down Expand Up @@ -222,7 +230,7 @@ function waitForNextImage(signal?: AbortSignal): Promise<ImageEvent | null> {

// Poll: check for new images by watching the background loop.
// We do this by subscribing to a one-time callback.
const key = `__glance_waiter_${Date.now()}`;
const key = nextWaiterKey();
(globalThis as any)[key] = (image: ImageEvent) => {
clearTimeout(timeout);
delete (globalThis as any)[key];
Expand All @@ -239,7 +247,7 @@ function waitForNextImage(signal?: AbortSignal): Promise<ImageEvent | null> {

function getWaiterKeys(): string[] {
return Object.keys(globalThis).filter((key) =>
key.startsWith("__glance_waiter_"),
key.startsWith(WAITER_PREFIX),
);
}

Expand Down Expand Up @@ -274,6 +282,7 @@ export const __testing = {
stopBackground();
sessionCreatedAt = 0;
running = false;
waiterCounter = 0;
clearWaiters();
},
setSession(session: SessionResponse | null, createdAt = Date.now()) {
Expand Down