Skip to content

Commit 9cee564

Browse files
committed
Split glance into glance + glance_wait tools, add tests
1 parent 5d18a8a commit 9cee564

File tree

2 files changed

+241
-12
lines changed

2 files changed

+241
-12
lines changed

opencode/glance.test.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest"
2+
3+
// Mock @opencode-ai/plugin — `tool()` is a passthrough that returns the config
4+
vi.mock("@opencode-ai/plugin", () => ({
5+
tool: (config: any) => config,
6+
}))
7+
8+
// Helper: dynamically import the plugin to get fresh module-level state
9+
async function loadPlugin() {
10+
vi.resetModules()
11+
vi.doMock("@opencode-ai/plugin", () => ({
12+
tool: (config: any) => config,
13+
}))
14+
const mod = await import("./glance.js")
15+
return mod.GlancePlugin
16+
}
17+
18+
function mockClient() {
19+
return { client: {} }
20+
}
21+
22+
function mockContext(abort?: AbortSignal) {
23+
return {
24+
metadata: vi.fn(),
25+
abort,
26+
}
27+
}
28+
29+
/**
30+
* Build a ReadableStream that emits the given SSE chunks, then hangs forever.
31+
* The hang prevents the background loop from spinning in a tight reconnect cycle.
32+
*/
33+
function sseStream(events: string[]) {
34+
const encoder = new TextEncoder()
35+
let i = 0
36+
return new ReadableStream({
37+
pull(controller) {
38+
if (i < events.length) {
39+
controller.enqueue(encoder.encode(events[i]))
40+
i++
41+
return
42+
}
43+
// Hang forever after all events are emitted
44+
return new Promise(() => {})
45+
},
46+
})
47+
}
48+
49+
function cleanupWaiters() {
50+
for (const key of Object.keys(globalThis)) {
51+
if (key.startsWith("__glance_waiter_")) {
52+
delete (globalThis as any)[key]
53+
}
54+
}
55+
}
56+
57+
/**
58+
* URL-aware fetch mock. Routes by URL so both the background loop and
59+
* tool calls get correct responses regardless of call order.
60+
*/
61+
function routedFetch(opts: {
62+
session?: { id: string; url: string }
63+
sessionError?: number
64+
sseEvents?: string[]
65+
}) {
66+
return vi.fn(async (url: string, _init?: any) => {
67+
if (url === "https://glance.sh/api/session") {
68+
if (opts.sessionError) {
69+
return { ok: false, status: opts.sessionError }
70+
}
71+
return {
72+
ok: true,
73+
json: async () => opts.session ?? { id: "test-id", url: "/s/test-id" },
74+
}
75+
}
76+
77+
if (typeof url === "string" && url.includes("/events")) {
78+
return {
79+
ok: true,
80+
body: sseStream(opts.sseEvents ?? []),
81+
}
82+
}
83+
84+
return { ok: false, status: 404 }
85+
})
86+
}
87+
88+
describe("opencode glance plugin", () => {
89+
afterEach(() => {
90+
vi.restoreAllMocks()
91+
cleanupWaiters()
92+
})
93+
94+
describe("glance tool", () => {
95+
it("creates a session and returns the URL", async () => {
96+
vi.stubGlobal(
97+
"fetch",
98+
routedFetch({ session: { id: "abc123", url: "/s/abc123" } }),
99+
)
100+
101+
const GlancePlugin = await loadPlugin()
102+
const plugin = await GlancePlugin(mockClient())
103+
const result = await plugin.tool.glance.execute({})
104+
105+
expect(result).toContain("https://glance.sh/s/abc123")
106+
expect(result).toContain("Session ready")
107+
})
108+
109+
it("reuses an existing session on second call", async () => {
110+
const fetchFn = routedFetch({
111+
session: { id: "abc123", url: "/s/abc123" },
112+
})
113+
vi.stubGlobal("fetch", fetchFn)
114+
115+
const GlancePlugin = await loadPlugin()
116+
const plugin = await GlancePlugin(mockClient())
117+
118+
// Let background loop create its session
119+
await new Promise((r) => setTimeout(r, 20))
120+
121+
const r1 = await plugin.tool.glance.execute({})
122+
const r2 = await plugin.tool.glance.execute({})
123+
124+
expect(r1).toContain("/s/abc123")
125+
expect(r2).toContain("/s/abc123")
126+
})
127+
128+
it("returns error when session creation fails", async () => {
129+
vi.stubGlobal("fetch", routedFetch({ sessionError: 500 }))
130+
131+
const GlancePlugin = await loadPlugin()
132+
const plugin = await GlancePlugin(mockClient())
133+
134+
// Wait for background loop to fail
135+
await new Promise((r) => setTimeout(r, 50))
136+
137+
const result = await plugin.tool.glance.execute({})
138+
expect(result).toContain("Failed to create session")
139+
})
140+
})
141+
142+
describe("glance_wait tool", () => {
143+
it("returns error when no session exists", async () => {
144+
vi.stubGlobal(
145+
"fetch",
146+
vi.fn().mockRejectedValue(new Error("no network")),
147+
)
148+
149+
const GlancePlugin = await loadPlugin()
150+
const plugin = await GlancePlugin(mockClient())
151+
152+
// Give background loop time to fail
153+
await new Promise((r) => setTimeout(r, 50))
154+
155+
const ctx = mockContext()
156+
const result = await plugin.tool.glance_wait.execute({}, ctx)
157+
expect(result).toContain("No active session")
158+
})
159+
160+
it("returns image URL when image is dispatched", async () => {
161+
const imagePayload = JSON.stringify({
162+
url: "https://glance.sh/tok123.png",
163+
expiresAt: Date.now() + 60_000,
164+
})
165+
166+
vi.stubGlobal(
167+
"fetch",
168+
routedFetch({
169+
session: { id: "sess1", url: "/s/sess1" },
170+
sseEvents: [
171+
`event: connected\ndata: {}\n\n`,
172+
`event: image\ndata: ${imagePayload}\n\n`,
173+
],
174+
}),
175+
)
176+
177+
const GlancePlugin = await loadPlugin()
178+
const plugin = await GlancePlugin(mockClient())
179+
180+
// Ensure session exists
181+
await plugin.tool.glance.execute({})
182+
183+
const ctx = mockContext()
184+
const result = await plugin.tool.glance_wait.execute({}, ctx)
185+
186+
expect(result).toContain("https://glance.sh/tok123.png")
187+
expect(ctx.metadata).toHaveBeenCalledWith(
188+
expect.objectContaining({
189+
title: expect.stringContaining("Waiting for paste"),
190+
}),
191+
)
192+
})
193+
194+
it("returns timeout message when aborted", async () => {
195+
vi.stubGlobal(
196+
"fetch",
197+
routedFetch({
198+
session: { id: "sess2", url: "/s/sess2" },
199+
}),
200+
)
201+
202+
const GlancePlugin = await loadPlugin()
203+
const plugin = await GlancePlugin(mockClient())
204+
await plugin.tool.glance.execute({})
205+
206+
const ac = new AbortController()
207+
const ctx = mockContext(ac.signal)
208+
209+
const waitPromise = plugin.tool.glance_wait.execute({}, ctx)
210+
await new Promise((r) => setTimeout(r, 50))
211+
ac.abort()
212+
213+
const result = await waitPromise
214+
expect(result).toContain("timed out")
215+
})
216+
})
217+
})

opencode/glance.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -232,11 +232,11 @@ export const GlancePlugin: Plugin = async ({ client }) => {
232232
glance: tool({
233233
description:
234234
"Open a live glance.sh session so the user can paste a screenshot from their browser. " +
235-
"The tool returns a session URL for the user to open, waits for them to paste an image, " +
236-
"and returns the image URL. Use this when you need to see the user's screen, a UI, an " +
237-
"error dialog, or anything visual.",
235+
"The tool returns a session URL for the user to open. After sharing the URL with the " +
236+
"user, call glance_wait to block until they paste an image. " +
237+
"Use this when you need to see the user's screen, a UI, an error dialog, or anything visual.",
238238
args: {},
239-
async execute(_args, _context) {
239+
async execute() {
240240
// Ensure session exists
241241
if (!currentSession) {
242242
try {
@@ -249,18 +249,30 @@ export const GlancePlugin: Plugin = async ({ client }) => {
249249
}
250250
}
251251

252+
const sessionUrl = `${BASE_URL}${currentSession!.url}`
253+
return `Session ready. Ask the user to paste an image at ${sessionUrl}`
254+
},
255+
}),
256+
257+
glance_wait: tool({
258+
description:
259+
"Wait for the user to paste an image into the glance.sh session. " +
260+
"Call glance first to get the session URL and share it with the user, " +
261+
"then call this tool to block until an image arrives. Returns the image URL.",
262+
args: {},
263+
async execute(_args, context) {
264+
if (!currentSession) {
265+
return "No active session. Call glance first to create one."
266+
}
267+
252268
const sessionUrl = `${BASE_URL}${currentSession!.url}`
253269

254-
await client.app.log({
255-
body: {
256-
service: "glance",
257-
level: "info",
258-
message: `Waiting for image at ${sessionUrl}`,
259-
},
270+
context.metadata({
271+
title: `Waiting for paste at ${sessionUrl}`,
272+
metadata: { sessionUrl },
260273
})
261274

262-
// Wait for the next image from the background listener
263-
const image = await waitForNextImage()
275+
const image = await waitForNextImage(context.abort)
264276

265277
if (!image) {
266278
return `Session timed out. Ask the user to paste an image at ${sessionUrl}`

0 commit comments

Comments
 (0)