Skip to content
Closed
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
46 changes: 46 additions & 0 deletions src/__tests__/workflow-local-tool-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,52 @@ describe('local tool adapter', () => {
});
});

it('passes the agent model and variant to opencode 1 as --model and --variant (#1193)', async () => {
const resultPath = path.join(os.tmpdir(), `teamai-workflow-opencode-model-1-${Date.now()}.json`);
files.push(resultPath);
await fs.writeFile(resultPath, JSON.stringify({ output: { ok: true } }));
const spawnTool = vi.fn((_command: string, args: string[]) =>
child(args.includes('--version') ? '1.18.23\n' : JSON.stringify({ type: 'session.created', properties: { info: { id: 'ses_1' } } }) + '\n'));
const adapter = createLocalToolAdapter(spawnTool as unknown as ToolSpawn, () => '/usr/bin/opencode');

await adapter.run('opencode', {
prompt: 'Review the change.',
cwd: os.tmpdir(),
resultPath,
agent: { name: 'reviewer', instructions: 'Read only.', model: 'anthropic/claude-sonnet-4-5', effort: 'high' },
});

const runArgs = spawnTool.mock.calls.find(([, args]) => (args as string[]).includes('run'))![1] as string[];
expect(runArgs).toContain('anthropic/claude-sonnet-4-5');
expect(runArgs).toContain('--model');
expect(runArgs).toContain('high');
expect(runArgs).toContain('--variant');
expect(runArgs.join(' ')).not.toContain('#high');
});

it('encodes the variant inside --model for opencode 2 (#1193)', async () => {
const resultPath = path.join(os.tmpdir(), `teamai-workflow-opencode-model-2-${Date.now()}.json`);
files.push(resultPath);
await fs.writeFile(resultPath, JSON.stringify({ output: { ok: true } }));
// A different executable path: the major is probed and cached per binary,
// so the OpenCode 2 fixture is its own probe, not the 1.x cache entry.
const spawnTool = vi.fn((_command: string, args: string[]) =>
child(args.includes('--version') ? '2.0.30\n' : JSON.stringify({ type: 'session.created', properties: { info: { id: 'ses_1' } } }) + '\n'));
const adapter = createLocalToolAdapter(spawnTool as unknown as ToolSpawn, () => '/opt/opencode-2/opencode');

await adapter.run('opencode', {
prompt: 'Review the change.',
cwd: os.tmpdir(),
resultPath,
agent: { name: 'reviewer', instructions: 'Read only.', model: 'anthropic/claude-sonnet-4-5', effort: 'high' },
});

const runArgs = spawnTool.mock.calls.find(([, args]) => (args as string[]).includes('run'))![1] as string[];
expect(runArgs).toContain('--model');
expect(runArgs).toContain('anthropic/claude-sonnet-4-5#high');
expect(runArgs).not.toContain('--variant');
});

it('reports tool_unavailable without spawning when the executable cannot be resolved', async () => {
const spawnTool = vi.fn() as unknown as ToolSpawn;
const adapter = createLocalToolAdapter(spawnTool, () => null);
Expand Down
13 changes: 12 additions & 1 deletion src/workflow/local-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
reverseFromClaude,
reverseFromCodex,
reverseFromOpencode,
toolExtrasFor,
type AgentSpec,
type ToolName,
} from '../resources/agent-format.js';
Expand Down Expand Up @@ -294,7 +295,17 @@ async function promptReport(
prompt: lease.prompt!,
cwd: driver.workspace,
resultPath: attemptResultPath,
...(agent ? { agent: { name: lease.agent!, instructions: agent.instructions, ...(agent.model ? { model: agent.model } : {}) } } : {}),
// The deployed OpenCode file carries the mapped effort as `variant`
// (agentEffortField); `run --agent` applies it only when the file is a
// primary agent, so it rides the command line instead (#1193).
...(agent ? { agent: {
name: lease.agent!,
instructions: agent.instructions,
...(agent.model ? { model: agent.model } : {}),
...(localToolFamily(selected) === 'opencode' && typeof toolExtrasFor(agent, selected as ToolName)?.variant === 'string'
? { effort: toolExtrasFor(agent, selected as ToolName)?.variant as string }
: {}),
} } : {}),
});
} catch {
result = {
Expand Down
54 changes: 50 additions & 4 deletions src/workflow/local-tool-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface LocalToolRun {
prompt: string;
cwd: string;
resultPath: string;
agent?: { name: string; instructions: string; model?: string };
agent?: { name: string; instructions: string; model?: string; effort?: string };
}

export interface LocalToolResult {
Expand Down Expand Up @@ -56,7 +56,32 @@ function resultInstruction(input: LocalToolRun, prompt: string): string {
+ 'Use this shape: {"output":{},"failure":{"code":"optional","message":"optional"}}.';
}

function invocation(tool: LocalWorkflowTool, input: LocalToolRun): { command: string; args: string[] } {
const opencodeMajorCache = new Map<string, number>();

/**
* OpenCode's major version, probed once per executable and cached. The two
* majors take an effort differently (`--variant` on 1, inside `--model` on 2,
* #1193); a failed probe reads as 1, the pinned devDependency's major.
*/
async function opencodeMajor(spawn: ToolSpawn, executable: string): Promise<number> {
const cached = opencodeMajorCache.get(executable);
if (cached !== undefined) return cached;
const major = await new Promise<number>((resolve) => {
let stdout = '';
try {
const probe = spawn(executable, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
probe.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
probe.on('close', () => resolve(Number.parseInt(stdout.trim(), 10) || 1));
probe.on('error', () => resolve(1));
} catch {
resolve(1);
}
});
opencodeMajorCache.set(executable, major);
return major;
}

function invocation(tool: LocalWorkflowTool, input: LocalToolRun, opencodeMajorVersion?: number): { command: string; args: string[] } {
const family = localToolFamily(tool);
const agentPrompt = input.agent?.instructions
? `${input.agent.instructions}\n\n${input.prompt}`
Expand All @@ -74,9 +99,25 @@ function invocation(tool: LocalWorkflowTool, input: LocalToolRun): { command: st
args: ['exec', '--json', ...(input.agent?.model ? ['--model', input.agent.model] : []), prompt],
};
}
// An agent step names a model the deployed file already carries, but
// `opencode run --agent` applies the file's model only when the file is a
// primary agent; passing it explicitly reaches both majors (#1193). The
// effort rides with it: `--variant` on OpenCode 1, inside the model id
// (`prov#effort`) on OpenCode 2, where run has no separate variant flag.
const model = input.agent?.model;
const effort = input.agent?.effort;
const version = opencodeMajorVersion ?? 1;
const modelArg = model !== undefined
? (effort !== undefined && version >= 2 ? `${model}#${effort}` : model)
: undefined;
return {
command: tool,
args: ['run', '--format', 'json', prompt, ...(input.agent ? ['--agent', input.agent.name] : [])],
args: [
'run', '--format', 'json', prompt,
...(input.agent ? ['--agent', input.agent.name] : []),
...(modelArg !== undefined ? ['--model', modelArg] : []),
...(effort !== undefined && model !== undefined && version < 2 ? ['--variant', effort] : []),
],
};
}

Expand Down Expand Up @@ -256,7 +297,12 @@ export function createLocalToolAdapter(
return {
async run(tool, input) {
await fs.mkdir(path.dirname(input.resultPath), { recursive: true });
const call = invocation(tool, input);
let major: number | undefined;
if (localToolFamily(tool) === 'opencode' && input.agent?.effort !== undefined) {
const executable = resolveTool(tool);
if (executable) major = await opencodeMajor(spawnTool, executable);
}
const call = invocation(tool, input, major);
const executable = resolveTool(call.command);
if (!executable) {
return {
Expand Down
Loading