Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/provider-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,11 @@ For [SSH remote agents](/ssh-remote-execution), maestro-p must be installed on t
| Slash commands | ❌ Not supported |
| Cost tracking | ✅ Per-step costs |
| Model selection | ✅ `--model provider/model` |
| Agent selection | ✅ `--agent <name>` |
| Context operations | ✅ Merge, export, and transfer |
| Thinking display | ✅ Streaming text chunks |

**Notes**:

- OpenCode uses the `run` subcommand which auto-approves all permissions (similar to Codex's YOLO mode). Maestro enables this via the `OPENCODE_CONFIG_CONTENT` environment variable.
- **OpenCode Agent** (in an agent's settings) picks the primary OpenCode agent for that Maestro agent, running it as `opencode run --agent <name>`. Use it to keep an agent pinned to a specific persona, model, and instruction set: point one Maestro agent at `build`, another at a plugin-provided agent, then group chat across them. Plugin agents (for example the ones oh-my-opencode registers) work even though `opencode agent list` doesn't print them, since OpenCode resolves the name at run time. The value is stored in that agent's Custom Arguments, so it is per-agent rather than shared across every OpenCode agent. Plan mode still forces `--agent plan` and ignores the selection for that turn.
120 changes: 120 additions & 0 deletions src/__tests__/main/utils/agent-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,126 @@ describe('applyAgentConfigOverrides', () => {
applyAgentConfigOverrides(agent, baseArgs, {});
expect(baseArgs).toEqual(['--print']);
});

// -- readOnlyMode: read-only flags must not be overridable --
//
// buildAgentArgs emits readOnlyArgs BEFORE these overrides are appended, so
// a repeat of the same flag would win on the CLI. OpenCode is the live case:
// plan mode is `--agent plan`, and a user selecting an OpenCode agent stores
// `--agent <name>` in their per-agent custom args.
describe('readOnlyMode flag pinning', () => {
const openCodeLike = makeAgent({
readOnlyArgs: ['--agent', 'plan'],
configOptions: [
{
key: 'model',
type: 'text',
label: 'Model',
description: 'Model',
default: '',
argBuilder: (val: any) => (val ? ['--model', String(val)] : []),
},
],
});

it('drops a custom-args --agent that would override plan mode', () => {
const result = applyAgentConfigOverrides(openCodeLike, ['run', '--agent', 'plan'], {
sessionCustomArgs: '--agent prometheus --verbose',
readOnlyMode: true,
});
expect(result.args).toEqual(['run', '--agent', 'plan', '--verbose']);
});

it('drops the `--agent=name` spelling too', () => {
const result = applyAgentConfigOverrides(openCodeLike, ['run', '--agent', 'plan'], {
sessionCustomArgs: '--agent=prometheus --verbose',
readOnlyMode: true,
});
expect(result.args).toEqual(['run', '--agent', 'plan', '--verbose']);
});

it('reports customArgsSource as none when every custom arg was dropped', () => {
const result = applyAgentConfigOverrides(openCodeLike, ['run', '--agent', 'plan'], {
sessionCustomArgs: '--agent prometheus',
readOnlyMode: true,
});
expect(result.args).toEqual(['run', '--agent', 'plan']);
expect(result.customArgsSource).toBe('none');
});

it('keeps the custom --agent when not in read-only mode', () => {
const result = applyAgentConfigOverrides(openCodeLike, ['run'], {
sessionCustomArgs: '--agent prometheus --verbose',
});
expect(result.args).toEqual(['run', '--agent', 'prometheus', '--verbose']);
});

it('leaves non-conflicting flags alone in read-only mode', () => {
const result = applyAgentConfigOverrides(openCodeLike, ['run', '--agent', 'plan'], {
sessionCustomModel: 'anthropic/claude-sonnet-4-20250514',
sessionCustomArgs: '--verbose',
readOnlyMode: true,
});
expect(result.args).toEqual([
'run',
'--agent',
'plan',
'--model',
'anthropic/claude-sonnet-4-20250514',
'--verbose',
]);
});

it('drops a config option that builds a pinned read-only flag', () => {
const agent = makeAgent({
readOnlyArgs: ['--agent', 'plan'],
configOptions: [
{
key: 'agent',
type: 'text',
label: 'Agent',
description: 'Agent',
default: '',
argBuilder: (val: any) => (val ? ['--agent', String(val)] : []),
},
],
});
const result = applyAgentConfigOverrides(agent, ['run', '--agent', 'plan'], {
agentConfigValues: { agent: 'prometheus' },
readOnlyMode: true,
});
expect(result.args).toEqual(['run', '--agent', 'plan']);
});

// Regression: a pinned flag with no value of its own (e.g. Codex's
// `--skip-git-repo-check`) used to eat whatever unrelated token followed
// it, since stripFlags couldn't tell a boolean switch from a value-taking
// one and only checked whether the next token looked like a flag.
const codexLike = makeAgent({
readOnlyArgs: [
'--sandbox',
'read-only',
'--dangerously-bypass-approvals-and-sandbox',
'--skip-git-repo-check',
],
});

it('does not eat an unrelated custom arg following a boolean pinned flag', () => {
const result = applyAgentConfigOverrides(codexLike, ['exec'], {
sessionCustomArgs: '--foo bar --skip-git-repo-check my-value',
readOnlyMode: true,
});
expect(result.args).toEqual(['exec', '--foo', 'bar', 'my-value']);
});

it('still eats the value for a pinned flag that genuinely takes one', () => {
const result = applyAgentConfigOverrides(codexLike, ['exec'], {
sessionCustomArgs: '--sandbox danger-full-access --other-flag keep-me',
readOnlyMode: true,
});
expect(result.args).toEqual(['exec', '--other-flag', 'keep-me']);
});
});
});

// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/main/utils/context-groomer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ describe('groomContext', () => {
agentConfigValues: { model: 'opus' },
sessionCustomArgs: '--extra',
sessionCustomEnvVars: { API_KEY: 'test' },
// Forwarded so a custom arg can't override a read-only flag
readOnlyMode: false,
});
});

Expand Down
70 changes: 70 additions & 0 deletions src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,76 @@ describe('AgentConfigPanel', () => {
});
});

describe('OpenCode Agent field', () => {
const openCodeAgent = createMockAgent({
id: 'opencode',
name: 'OpenCode',
binaryName: 'opencode',
path: '/usr/local/bin/opencode',
});

it('is hidden for non-OpenCode providers', () => {
render(<AgentConfigPanel {...createDefaultProps()} />);

expect(screen.queryByText('OpenCode Agent (optional)')).not.toBeInTheDocument();
});

it('renders for OpenCode', () => {
render(<AgentConfigPanel {...createDefaultProps({ agent: openCodeAgent })} />);

expect(screen.getByText('OpenCode Agent (optional)')).toBeInTheDocument();
});

it('shows the agent name parsed out of the existing custom args', () => {
render(
<AgentConfigPanel
{...createDefaultProps({
agent: openCodeAgent,
customArgs: '--verbose --agent prometheus',
})}
/>
);

expect(screen.getByDisplayValue('prometheus')).toBeInTheDocument();
});

it('writes the name into custom args, preserving the other args', () => {
const onCustomArgsChange = vi.fn();
render(
<AgentConfigPanel
{...createDefaultProps({
agent: openCodeAgent,
customArgs: '--verbose',
onCustomArgsChange,
})}
/>
);

fireEvent.change(screen.getByPlaceholderText('build'), {
target: { value: 'sisyphus' },
});

expect(onCustomArgsChange).toHaveBeenCalledWith('--verbose --agent sisyphus');
});

it('removes the flag when the field is cleared', () => {
const onCustomArgsChange = vi.fn();
render(
<AgentConfigPanel
{...createDefaultProps({
agent: openCodeAgent,
customArgs: '--agent prometheus --verbose',
onCustomArgsChange,
})}
/>
);

fireEvent.change(screen.getByPlaceholderText('build'), { target: { value: '' } });

expect(onCustomArgsChange).toHaveBeenCalledWith('--verbose');
});
});

describe('Custom environment variables', () => {
it('should render custom env vars', () => {
const customEnvVars = {
Expand Down
117 changes: 117 additions & 0 deletions src/__tests__/shared/opencodeAgentArg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Tests for src/shared/opencodeAgentArg.ts
*
* The OpenCode agent picker stores its value inside the per-agent Custom CLI
* Args string, so these helpers must round-trip cleanly and never disturb the
* other arguments a user has typed there.
*/

import { describe, it, expect } from 'vitest';
import { readOpenCodeAgentArg, writeOpenCodeAgentArg } from '../../shared/opencodeAgentArg';

describe('readOpenCodeAgentArg', () => {
it('returns empty string for undefined or empty input', () => {
expect(readOpenCodeAgentArg(undefined)).toBe('');
expect(readOpenCodeAgentArg('')).toBe('');
});

it('returns empty string when no --agent flag is present', () => {
expect(readOpenCodeAgentArg('--model anthropic/claude-sonnet-4-20250514')).toBe('');
});

it('reads the `--agent name` form', () => {
expect(readOpenCodeAgentArg('--agent prometheus')).toBe('prometheus');
});

it('reads the `--agent=name` form', () => {
expect(readOpenCodeAgentArg('--agent=prometheus')).toBe('prometheus');
});

it('reads the value from the middle of a longer arg string', () => {
expect(readOpenCodeAgentArg('--foo bar --agent sisyphus --baz')).toBe('sisyphus');
});

it('unquotes a quoted value', () => {
expect(readOpenCodeAgentArg('--agent "my agent"')).toBe('my agent');
expect(readOpenCodeAgentArg("--agent='my agent'")).toBe('my agent');
});

it('ignores a dangling --agent with no value', () => {
expect(readOpenCodeAgentArg('--agent')).toBe('');
expect(readOpenCodeAgentArg('--agent --verbose')).toBe('');
});
});

describe('writeOpenCodeAgentArg', () => {
it('appends the flag when the arg string is empty', () => {
expect(writeOpenCodeAgentArg('', 'prometheus')).toBe('--agent prometheus');
expect(writeOpenCodeAgentArg(undefined, 'prometheus')).toBe('--agent prometheus');
});

it('appends the flag while preserving existing args', () => {
expect(writeOpenCodeAgentArg('--verbose --foo bar', 'plan')).toBe(
'--verbose --foo bar --agent plan'
);
});

it('replaces an existing value in place', () => {
expect(writeOpenCodeAgentArg('--foo --agent build --bar', 'plan')).toBe(
'--foo --agent plan --bar'
);
});

it('normalizes the `--agent=name` form when replacing', () => {
expect(writeOpenCodeAgentArg('--agent=build --bar', 'plan')).toBe('--agent plan --bar');
});

it('removes the flag when the name is empty or whitespace', () => {
expect(writeOpenCodeAgentArg('--foo --agent build --bar', '')).toBe('--foo --bar');
expect(writeOpenCodeAgentArg('--agent build', ' ')).toBe('');
});

it('collapses duplicate --agent flags down to the first position', () => {
expect(writeOpenCodeAgentArg('--agent build --foo --agent other', 'plan')).toBe(
'--agent plan --foo'
);
});

it('drops a dangling --agent with no value', () => {
expect(writeOpenCodeAgentArg('--foo --agent', 'plan')).toBe('--foo --agent plan');
});

it('quotes values containing whitespace', () => {
expect(writeOpenCodeAgentArg('', 'my agent')).toBe('--agent "my agent"');
});

it('trims the provided name', () => {
expect(writeOpenCodeAgentArg('', ' plan ')).toBe('--agent plan');
});

it('round-trips through read', () => {
const written = writeOpenCodeAgentArg('--model gpt-5.2', 'oracle');
expect(readOpenCodeAgentArg(written)).toBe('oracle');
});

// tokenize() has no escape syntax, so a quote in the name cannot round-trip.
// Stripping keeps the argument list well-formed; emitting it raw split the
// name in two and turned the tail into a separate argument.
it('strips quote characters instead of corrupting the argument list', () => {
const written = writeOpenCodeAgentArg('--print', 'my"agent');
expect(readOpenCodeAgentArg(written)).toBe('myagent');
expect(written).toContain('--print');
});

it('strips single quotes too', () => {
const written = writeOpenCodeAgentArg('--print', "o'brien");
expect(readOpenCodeAgentArg(written)).toBe('obrien');
});

it('still quotes a name containing whitespace', () => {
const written = writeOpenCodeAgentArg('', 'my agent');
expect(readOpenCodeAgentArg(written)).toBe('my agent');
});

it('leaves a plain name unquoted', () => {
expect(writeOpenCodeAgentArg('', 'build')).toBe('--agent build');
});
});
10 changes: 7 additions & 3 deletions src/cli/services/agent-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ function resolveAgentOverrides(
toolType: ToolType,
def: ReturnType<typeof getAgentDefinition>,
baseArgs: string[],
overrides: SpawnOverrides
overrides: SpawnOverrides,
readOnlyMode?: boolean
): { args: string[]; userCustomEnvVars?: Record<string, string> } {
const agentConfigValues = readAgentConfig(toolType);
const result = applyAgentConfigOverrides(def ?? null, baseArgs, {
Expand All @@ -150,6 +151,7 @@ function resolveAgentOverrides(
sessionCustomEffort: overrides.customEffort,
sessionCustomArgs: overrides.customArgs,
sessionCustomEnvVars: overrides.customEnvVars,
readOnlyMode,
});
const userCustomEnvVars =
overrides.customEnvVars ??
Expand Down Expand Up @@ -434,7 +436,8 @@ async function spawnClaudeAgent(
'claude-code',
def,
preOverrideArgs,
overrides
overrides,
readOnlyMode
);

// Inject the Maestro system prompt via `--append-system-prompt(-file)`. The
Expand Down Expand Up @@ -832,7 +835,8 @@ async function spawnJsonLineAgent(
toolType,
def,
preOverrideArgs,
overrides
overrides,
readOnlyMode
);

// Pass only the user-level env (no agent defaults) so shell-provided values
Expand Down
Loading