Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
396e04c
ralph: work on #29 (iter 1)
jharris1679 Feb 16, 2026
5ff7b67
ralph: work on #29 (iter 2)
jharris1679 Feb 16, 2026
90afa8e
ralph: work on #29 (iter 3)
jharris1679 Feb 16, 2026
8db2cf9
ralph: work on #29 (iter 4)
jharris1679 Feb 16, 2026
dca0a62
ralph: work on #29 (iter 5)
jharris1679 Feb 16, 2026
5e68155
ralph: work on #29 (iter 6)
jharris1679 Feb 16, 2026
591e66a
ralph: work on #29 (iter 7)
jharris1679 Feb 16, 2026
1907aee
ralph: work on #29 (iter 8)
jharris1679 Feb 16, 2026
3c15eea
fix: resolve syntax errors in llm-judge.ts and runner.ts (#29)
jharris1679 Feb 16, 2026
bfd1865
ralph: work on #29 (iter 10)
jharris1679 Feb 16, 2026
fe5fdbe
ralph: work on #29 (iter 11)
jharris1679 Feb 16, 2026
51cc8a0
ralph: work on #29 (iter 14)
jharris1679 Feb 16, 2026
6201c1c
fix: resolve type errors in llm-judge and runner (#29)
jharris1679 Feb 16, 2026
accb89b
fix: resolve TypeScript build errors in llm-judge and runner (#29)
jharris1679 Feb 16, 2026
202890f
ralph: work on #29 (iter 17)
jharris1679 Feb 16, 2026
ba89155
ralph: work on #29 (iter 24)
jharris1679 Feb 16, 2026
731e6ef
ralph: work on #29 (iter 25)
jharris1679 Feb 16, 2026
ba82825
ralph: work on #29 (iter 26)
jharris1679 Feb 16, 2026
8aa5584
ralph: work on #29 (iter 27)
jharris1679 Feb 16, 2026
d3124fe
ralph: work on #29 (iter 28)
jharris1679 Feb 16, 2026
a356603
ralph: work on #29 (iter 29)
jharris1679 Feb 16, 2026
22968cb
ralph: work on #29 (iter 30)
jharris1679 Feb 16, 2026
f8a4c81
ralph: work on #29 (iter 31)
jharris1679 Feb 16, 2026
e8dbf84
fix: resolve TypeScript type errors in opencode agent (#29)
jharris1679 Feb 16, 2026
fead796
ralph: work on #29 (iter 33)
jharris1679 Feb 16, 2026
48d3025
ralph: work on #29 (iter 35)
jharris1679 Feb 16, 2026
9ce33a0
ralph: work on #29 (iter 36)
jharris1679 Feb 16, 2026
58596fd
ralph: work on #29 (iter 37)
jharris1679 Feb 16, 2026
b5825e1
ralph: work on #29 (iter 38)
jharris1679 Feb 16, 2026
57d57fb
ralph: work on #29 (iter 41)
jharris1679 Feb 16, 2026
49343b6
ralph: work on #29 (iter 42)
jharris1679 Feb 16, 2026
0837c90
ralph: work on #29 (iter 43)
jharris1679 Feb 16, 2026
5e5c120
ralph: work on #29 (iter 46)
jharris1679 Feb 16, 2026
3573088
ralph: work on #29 (iter 48)
jharris1679 Feb 16, 2026
0e61dac
ralph: work on #29 (iter 49)
jharris1679 Feb 16, 2026
17db07f
ralph: work on #29 (iter 50)
jharris1679 Feb 16, 2026
a6df782
ralph: work on #29 (iter 51)
jharris1679 Feb 16, 2026
e92fea0
ralph: work on #29 (iter 52)
jharris1679 Feb 16, 2026
a039b62
ralph: work on #29 (iter 53)
jharris1679 Feb 16, 2026
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
2 changes: 1 addition & 1 deletion src/agents/opencode-sdk.mjs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
* Type declarations for opencode-sdk.mjs wrapper
*/

declare const createOpencodeClient: any;
declare const createOpencodeClient: unknown;

export { createOpencodeClient };
139 changes: 75 additions & 64 deletions src/agents/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
} from './types.js';

// Import SDK client dynamically since it's ESM-only
let _createOpencodeClient: any;
let _createOpencodeClient: unknown;
const loadSDK = async () => {
if (!_createOpencodeClient) {
const sdkWrapper = await import('./opencode-sdk.mjs');
Expand All @@ -34,7 +34,7 @@ let nextPort = 4097;
*/
async function spawnServer(
cwd: string,
config: Record<string, any>,
config: Record<string, unknown>,
timeoutMs: number,
): Promise<{ url: string; proc: ChildProcess }> {
const port = nextPort++;
Expand All @@ -46,8 +46,8 @@ async function spawnServer(
},
});

const url = await new Promise<string>((resolve, reject) => {
const id = setTimeout(() => {
const _url = await new Promise<string>((resolve, reject) => { // eslint-disable-line @typescript-eslint/no-unused-vars
const _id = setTimeout(() => {
proc.kill();
reject(new Error(`Timeout waiting for opencode server after ${timeoutMs}ms`));
}, timeoutMs);
Expand All @@ -59,7 +59,7 @@ async function spawnServer(
if (line.startsWith('opencode server listening')) {
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
if (match) {
clearTimeout(id);
clearTimeout(_id);
resolve(match[1]);
return;
}
Expand All @@ -70,16 +70,16 @@ async function spawnServer(
output += chunk.toString();
});
proc.on('exit', (code) => {
clearTimeout(id);
clearTimeout(_id);
reject(new Error(`Server exited with code ${code}: ${output}`));
});
proc.on('error', (err) => {
clearTimeout(id);
clearTimeout(_id);
reject(err);
});
});

return { url, proc };
return { url: _url, proc };
}

/**
Expand All @@ -90,9 +90,9 @@ export class OpencodeAgent implements AgentWrapper {
displayName = 'Opencode';

private cliPath: string;
private config: Record<string, any>;
private config: Record<string, unknown>;

constructor(cliPath: string = 'opencode', config?: Record<string, any>) {
constructor(cliPath: string = 'opencode', config?: Record<string, unknown>) {
this.cliPath = cliPath;
this.config = config || {
model: 'local-glm/glm-4.7-local-4bit',
Expand Down Expand Up @@ -149,19 +149,20 @@ export class OpencodeAgent implements AgentWrapper {
const toolCalls: ToolCall[] = [];
let model = 'unknown';
let sessionId = '';
let serverProc: ChildProcess | null = null;
let _serverProc: ChildProcess | null = null;

try {
// Spawn server in the case's working directory
const cwd = options.cwd || process.cwd();
const config = options.model
? { ...this.config, model: options.model }
: this.config;
const { url, proc } = await spawnServer(cwd, config, 15000);
serverProc = proc;
const { url: _url, proc } = await spawnServer(cwd, config, 15000);
_serverProc = proc;

const createClient = await loadSDK();
const client = createClient({ baseUrl: url });
if (!createClient) throw new Error("Failed to load SDK");
const client = (createClient as () => any)(); // eslint-disable-line @typescript-eslint/no-explicit-any

const createResult = await client.session.create({});
if (createResult.error) {
Expand All @@ -176,9 +177,11 @@ export class OpencodeAgent implements AgentWrapper {

// Subscribe to SSE events BEFORE sending the prompt so we capture everything
// event.subscribe() returns ServerSentEventsResult directly (not { data, error })
const sseResult = await client.event.subscribe({}) as any;
const stream: AsyncIterable<any> | undefined =
sseResult?.stream || sseResult?.data?.stream || sseResult?.data;
const sseResult = await client.event.subscribe({}) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
const stream: AsyncIterable<unknown> | undefined =
(sseResult as { stream?: AsyncIterable<unknown>; data?: { stream?: AsyncIterable<unknown> } })?.stream ||
(sseResult as { data?: { stream?: AsyncIterable<unknown> } })?.data?.stream ||
(sseResult as { data?: AsyncIterable<unknown> })?.data;

if (!stream) {
throw new Error(
Expand All @@ -202,7 +205,7 @@ export class OpencodeAgent implements AgentWrapper {
let answer = '';
let numTurns = 0;
let totalTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
let totalCost = 0;
let totalCost: number = 0;
const deadline = Date.now() + timeoutMs - 5000;

for await (const event of stream) {
Expand All @@ -211,33 +214,36 @@ export class OpencodeAgent implements AgentWrapper {
break;
}

const eventType = event?.type || event?.event;
const eventType = (event as { type?: string; event?: string })?.type ?? (event as { type?: string; event?: string })?.event ?? '';

if (eventType === 'message.part.updated') {
const props = event.properties || event.data;
const eventAny = event as { properties?: unknown; data?: unknown };
const props = eventAny.properties || eventAny.data || {};
if (!props) continue;
const part = props.part;
const part = (props as { part?: unknown }).part || ({} as Record<string, unknown>);
if (!part) continue;

if (part.type === 'text') {
const partAny = part as { type?: string; text?: string; state?: { status?: string; input?: unknown; time?: { start?: number; end?: number }; output?: unknown }; callID?: string; callId?: string; tool?: string; tokens?: { input?: number; output?: number; cache?: { read?: number; write?: number }; total?: number }; cost?: number };
if (partAny.type === 'text') {
// Streaming text delta
const delta = props.delta || '';
const delta = (props as { delta?: string }).delta || '';
if (delta) {
answer += delta;
options.onEvent?.({ type: 'text_delta', text: delta });
}
} else if (part.type === 'tool') {
const status = part.state?.status;
const callID = part.callID || part.callId;
const toolName = part.tool || 'unknown';
} else if (partAny.type === 'tool') {
const status = partAny.state?.status || '';
const callID = partAny.callID || partAny.callId || '';
const toolName: string = (partAny.tool as string) || 'unknown';
if (!toolName) continue;

if (status === 'running' || status === 'pending') {
// Only add if not already tracked
if (!toolCalls.find((t) => t.id === callID)) {
const toolCall: ToolCall = {
id: callID,
name: toolName,
input: part.state?.input || {},
input: (partAny.state?.input || {}) as Record<string, unknown>,
timestamp: Date.now(),
};
toolCalls.push(toolCall);
Expand All @@ -247,26 +253,26 @@ export class OpencodeAgent implements AgentWrapper {
} else if (status === 'completed') {
const existing = toolCalls.find((t) => t.id === callID);
if (existing) {
existing.durationMs = part.state?.time
? (part.state.time.end - part.state.time.start) * 1000
existing.durationMs = partAny.state?.time?.end && partAny.state.time?.start
? (partAny.state.time.end - partAny.state.time.start) * 1000
: Date.now() - existing.timestamp;
existing.success = true;
existing.result = part.state?.output
? String(part.state.output).substring(0, 500)
existing.result = partAny.state?.output
? String(partAny.state.output).substring(0, 500)
: undefined;
} else {
// Tool completed without a prior start event (can happen if subscription started late)
toolCalls.push({
id: callID,
name: toolName,
input: part.state?.input || {},
input: (partAny.state?.input || {}) as Record<string, unknown>,
timestamp: Date.now(),
durationMs: part.state?.time
? (part.state.time.end - part.state.time.start) * 1000
durationMs: partAny.state?.time?.end && partAny.state.time?.start
? (partAny.state.time.end - partAny.state.time.start) * 1000
: 0,
success: true,
result: part.state?.output
? String(part.state.output).substring(0, 500)
result: partAny.state?.output
? String(partAny.state.output).substring(0, 500)
: undefined,
});
}
Expand All @@ -289,29 +295,32 @@ export class OpencodeAgent implements AgentWrapper {
durationMs: existing?.durationMs || 0,
});
}
} else if (part.type === 'reasoning') {
const text = props.delta || part.text || '';
} else if (partAny.type === 'reasoning') {
const text = (props as { delta?: string }).delta || partAny.text || '';
if (!text) continue;
if (text) {
options.onEvent?.({ type: 'thinking', text });
}
} else if (part.type === 'step-finish') {
} else if (partAny.type === 'step-finish') {
numTurns++;
// Accumulate per-step tokens/cost
if (part.tokens) {
totalTokens.input += part.tokens.input || 0;
totalTokens.output += part.tokens.output || 0;
totalTokens.cacheRead += part.tokens.cache?.read || 0;
totalTokens.cacheWrite += part.tokens.cache?.write || 0;
totalTokens.total += part.tokens.total || 0;
const partTyped = partAny as { tokens?: { input?: number; output?: number; cache?: { read?: number; write?: number }; total?: number }; cost?: number };
if (partTyped.tokens) {
totalTokens.input += partTyped.tokens.input || 0;
totalTokens.output += partTyped.tokens.output || 0;
totalTokens.cacheRead += partTyped.tokens.cache?.read || 0;
totalTokens.cacheWrite += partTyped.tokens.cache?.write || 0;
totalTokens.total += partTyped.tokens.total || 0;
}
if (part.cost) {
totalCost += part.cost;
if (partTyped.cost) {
totalCost += partTyped.cost;
}
}
} else if (eventType === 'message.updated') {
// A full message update — extract final info from here
const props = event.properties || event.data;
const info = props?.info;
const eventAny = event as { properties?: unknown; data?: unknown };
const props = (eventAny.properties || eventAny.data) as { parts?: unknown[] } & Record<string, unknown>;
const info = props as { providerID?: string; modelID?: string; tokens?: { input?: number; output?: number; cache?: { read?: number; write?: number }; total?: number }; cost?: number } | undefined;
if (info?.providerID && info?.modelID) {
model = `${info.providerID}/${info.modelID}`;
}
Expand All @@ -329,16 +338,17 @@ export class OpencodeAgent implements AgentWrapper {
totalCost = info.cost;
}
// Extract final answer text from message parts if we haven't captured it via deltas
if (props?.parts && !answer) {
for (const p of props.parts) {
if (p.type === 'text' && p.text) {
answer += p.text;
if (props && (props as { parts?: unknown[] } & Record<string, unknown>).parts) {
for (const p of (props as { parts?: unknown[] | null | undefined }).parts ?? []) {
if ((p as { type?: string; text?: string }).type === 'text' && (p as { type?: string; text?: string }).text) {
answer += (p as { type?: string; text?: string }).text;
}
}
}
Comment on lines 340 to 347
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential duplicate answer content from message.updated events.

When streaming deltas are received (lines 229-233), answer accumulates text. Then message.updated (lines 341-346) unconditionally appends text from the full message parts. This can double the captured answer. The fallback at lines 373-392 already handles the "no streaming data" case. Guard this section:

           // Extract final answer text from message parts if we haven't captured it via deltas
-          if (props && (props as { parts?: unknown[] } & Record<string, unknown>).parts) {
+          if (!answer && props && (props as { parts?: unknown[] } & Record<string, unknown>).parts) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Extract final answer text from message parts if we haven't captured it via deltas
if (props?.parts && !answer) {
for (const p of props.parts) {
if (p.type === 'text' && p.text) {
answer += p.text;
if (props && (props as { parts?: unknown[] } & Record<string, unknown>).parts) {
for (const p of (props as { parts?: unknown[] | null | undefined }).parts ?? []) {
if ((p as { type?: string; text?: string }).type === 'text' && (p as { type?: string; text?: string }).text) {
answer += (p as { type?: string; text?: string }).text;
}
}
}
// Extract final answer text from message parts if we haven't captured it via deltas
if (!answer && props && (props as { parts?: unknown[] } & Record<string, unknown>).parts) {
for (const p of (props as { parts?: unknown[] | null | undefined }).parts ?? []) {
if ((p as { type?: string; text?: string }).type === 'text' && (p as { type?: string; text?: string }).text) {
answer += (p as { type?: string; text?: string }).text;
}
}
}
🤖 Prompt for AI Agents
In `@src/agents/opencode.ts` around lines 340 - 347, The message.updated handler
unconditionally appends text from props.parts into the answer, which can
duplicate content already appended by the streaming delta handler; add a guard
so props.parts is only merged when no streaming deltas were received (or when
answer is still empty). Concretely, introduce or reuse a boolean flag set to
true in the streaming-delta processing code (the code that appends to answer
from deltas) such as hasStreamedDeltas or receivedDeltas, then in the
message.updated block that reads props.parts only append when that flag is false
(or answer is empty), and ensure the flag is initialized/cleared appropriately
so fallback behavior still works when there were no streaming deltas.

} else if (eventType === 'session.status') {
const props = event.properties || event.data;
const status = props?.status;
const eventAny = event as { properties?: unknown; data?: unknown };
const props = (eventAny.properties || eventAny.data) as { parts?: unknown[] } & Record<string, unknown>;
const status = props as { type?: string; attempt?: number; message?: string } | undefined;
if (status?.type === 'idle') {
// Agent finished processing
options.onEvent?.({ type: 'status', message: 'Session idle — agent finished' });
Expand All @@ -352,8 +362,9 @@ export class OpencodeAgent implements AgentWrapper {
});
}
} else if (eventType === 'session.error') {
const props = event.properties || event.data;
const errMsg = props?.error?.message || JSON.stringify(props?.error) || 'Unknown error';
const eventAny = event as { properties?: unknown; data?: unknown };
const props = (eventAny.properties || eventAny.data) as { parts?: unknown[] } & Record<string, unknown>;
const errMsg = (props as { error?: { message?: string } | undefined })?.error?.message || JSON.stringify(props) || 'Unknown error';
options.onEvent?.({ type: 'error', message: errMsg, code: 'SESSION_ERROR' });
}
}
Expand All @@ -364,14 +375,14 @@ export class OpencodeAgent implements AgentWrapper {
path: { id: sessionId },
});
if (messagesResult.data) {
const messages = messagesResult.data as any[];
const messages = messagesResult.data as { role?: string; parts?: unknown[] }[];
// Find the last assistant message
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === 'assistant' && msg.parts) {
const msg = messages[i] as { role?: string; parts?: unknown[] };
if ((msg as { role?: string }).role === 'assistant' && msg.parts) {
for (const p of msg.parts) {
if (p.type === 'text' && p.text) {
answer += p.text;
if ((p as { type?: string; text?: string }).type === 'text' && (p as { type?: string; text?: string }).text) {
answer += (p as { type?: string; text?: string }).text;
}
}
break;
Expand Down Expand Up @@ -416,7 +427,7 @@ export class OpencodeAgent implements AgentWrapper {
options.onEvent?.({ type: 'complete', result: errorResult });
return errorResult;
} finally {
serverProc?.kill();
_serverProc?.kill();
}
}
}
Expand Down
Loading