/** * Capture native OpenCode server API output for comparison. * * Usage: * npx tsx capture-native.ts * * Starts a native OpenCode headless server, creates a Claude session, * sends 2 messages (one that triggers tool calls), and captures all * session events and message snapshots. */ import { spawn, type ChildProcess } from "node:child_process"; import { writeFileSync, mkdirSync, existsSync } from "node:fs"; import { createServer, type AddressInfo } from "node:net"; const OUTPUT_DIR = new URL("./snapshots/native", import.meta.url).pathname; async function getFreePort(): Promise { return new Promise((resolve, reject) => { const server = createServer(); server.unref(); server.on("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address() as AddressInfo; server.close(() => resolve(address.port)); }); }); } async function waitForHealth(baseUrl: string, timeoutMs = 30_000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const res = await fetch(`${baseUrl}/global/health`); if (res.ok) return; } catch {} await new Promise((r) => setTimeout(r, 300)); } throw new Error("Timed out waiting for native opencode health"); } function saveJson(name: string, data: unknown) { if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true }); const path = `${OUTPUT_DIR}/${name}.json`; writeFileSync(path, JSON.stringify(data, null, 2)); console.log(` [saved] ${path}`); } async function waitForIdle(baseUrl: string, sessionId: string, timeoutMs: number): Promise { const start = Date.now(); // Give a small initial delay for the status to change to busy await new Promise((r) => setTimeout(r, 500)); while (Date.now() - start < timeoutMs) { try { const statusRes = await fetch(`${baseUrl}/session/status`); const statuses = await statusRes.json(); const sessionStatus = statuses?.[sessionId]; if (sessionStatus?.type === "idle" || sessionStatus === undefined) { return; } } catch {} await new Promise((r) => setTimeout(r, 500)); } throw new Error("Timed out waiting for session to become idle"); } async function main() { const port = await getFreePort(); const baseUrl = `http://127.0.0.1:${port}`; console.log(`Starting native OpenCode server on port ${port}...`); const child: ChildProcess = spawn("opencode", ["serve", "--port", String(port)], { stdio: "pipe", env: { ...process.env }, }); let stderr = ""; child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); child.stdout?.on("data", (chunk) => { const text = chunk.toString(); if (text.includes("listening")) console.log(` [opencode] ${text.trim()}`); }); // Track all SSE events in a separate array const allEvents: any[] = []; let sseAbort: AbortController | null = null; let currentBaseUrl = ""; try { await waitForHealth(baseUrl); currentBaseUrl = baseUrl; console.log("Native OpenCode server is healthy!"); // 1. Capture initial metadata const [agentRes, configRes] = await Promise.all([ fetch(`${baseUrl}/agent`).then((r) => r.json()), fetch(`${baseUrl}/config`).then((r) => r.json()), ]); saveJson("metadata-agent", agentRes); saveJson("metadata-config", configRes); // 2. Start SSE event collection sseAbort = new AbortController(); const ssePromise = (async () => { try { const res = await fetch(`${baseUrl}/event`, { signal: sseAbort!.signal, headers: { Accept: "text/event-stream" }, }); if (!res.ok || !res.body) { console.error("SSE connection failed:", res.status); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.startsWith("data: ")) { try { const parsed = JSON.parse(line.slice(6)); allEvents.push(parsed); // Auto-approve permissions if (parsed.type === "permission.asked" && parsed.properties?.id) { const permId = parsed.properties.id; console.log(` [auto-approving permission ${permId}]`); fetch(`${currentBaseUrl}/permission/${permId}/reply`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ allow: true }), }).catch(() => {}); } } catch {} } } } } catch (err: any) { if (err.name !== "AbortError") { // Ignore - expected when server closes } } })(); // Give SSE time to connect await new Promise((r) => setTimeout(r, 500)); // 3. Create a session console.log("Creating session..."); const sessionRes = await fetch(`${baseUrl}/session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); const session = await sessionRes.json(); saveJson("session-create", session); const sessionId = session.id; console.log(` Session ID: ${sessionId}`); // Use anthropic provider with a cheap model for testing const model = { providerID: "anthropic", modelID: "claude-haiku-4-5" }; // 4. Send first message (simple text response) - use prompt_async + wait console.log("Sending message 1 (simple text)..."); await fetch(`${baseUrl}/session/${sessionId}/prompt_async`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, parts: [{ type: "text", text: "Respond with exactly: 'Hello from OpenCode'. Nothing else." }], }), }); // Wait for the response to be fully processed console.log(" Waiting for message 1 to complete..."); await waitForIdle(baseUrl, sessionId, 60_000); await new Promise((r) => setTimeout(r, 1000)); // 5. Get messages after first request const messagesAfter1 = await fetch(`${baseUrl}/session/${sessionId}/message`).then((r) => r.json() ); saveJson("messages-after-1", messagesAfter1); console.log(` Got ${messagesAfter1.length} messages after msg 1`); // 6. Send second message (ask for a tool call - file write) - use prompt_async console.log("Sending message 2 (should trigger tool calls)..."); await fetch(`${baseUrl}/session/${sessionId}/prompt_async`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, parts: [ { type: "text", text: "List the files in the current directory. Use the list/ls tool. Only list the top-level contents, do not recurse.", }, ], }), }); // Wait for completion (longer timeout for tool calls + permissions) console.log(" Waiting for message 2 to complete..."); try { await waitForIdle(baseUrl, sessionId, 120_000); } catch (e) { console.log(" Warning: timed out waiting for idle, capturing what we have..."); } await new Promise((r) => setTimeout(r, 2000)); // 7. Get messages after second request const messagesAfter2 = await fetch(`${baseUrl}/session/${sessionId}/message`).then((r) => r.json() ); saveJson("messages-after-2", messagesAfter2); console.log(` Got ${messagesAfter2.length} messages after msg 2`); // 8. Get session details const sessionDetails = await fetch(`${baseUrl}/session/${sessionId}`).then((r) => r.json()); saveJson("session-details", sessionDetails); // 9. Get session status const sessionStatus = await fetch(`${baseUrl}/session/status`).then((r) => r.json()); saveJson("session-status", sessionStatus); // 10. Stop SSE and save events sseAbort.abort(); await new Promise((r) => setTimeout(r, 500)); saveJson("all-events", allEvents); // Filter events for this session const sessionEvents = allEvents.filter( (e) => e.properties?.sessionID === sessionId || (e.type === "session.created" && e.properties?.info?.id === sessionId) ); saveJson("session-events", sessionEvents); console.log(`\nCapture complete! ${allEvents.length} total events, ${sessionEvents.length} session events.`); console.log(`Output saved to: ${OUTPUT_DIR}/`); } finally { if (sseAbort) sseAbort.abort(); child.kill("SIGTERM"); await new Promise((r) => setTimeout(r, 1000)); if (child.exitCode === null) child.kill("SIGKILL"); } } main().catch((err) => { console.error("Fatal error:", err); process.exit(1); });