mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 20:01:27 +00:00
fix: opencode compat tool call rendering and default to no-token
- Fix tool name lost on ToolResult events (persist via tool_name_by_call) - Fix tool input lost on ToolResult events (persist via tool_args_by_call) - Fix tool output in wrong field (error -> output) - Fix text doubling in streaming (defer emit to ItemCompleted) - Fix missing delta field in text streaming events - Default server mode to no-token when --token not specified - Add install-fast-sa and install-fast-gigacode justfile targets
This commit is contained in:
parent
8663f5070c
commit
6b3a620fa4
32 changed files with 9193 additions and 38 deletions
260
research/opencode-compat/capture-native.ts
Normal file
260
research/opencode-compat/capture-native.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
/**
|
||||
* 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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue