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
149 changes: 149 additions & 0 deletions src/main/acp/acp-dialects/grok-launch-args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import { StructuredAgentArgumentsError } from '../../native-chat/structured-agent-arguments-error'
import { grokAgentArgv } from './grok-launch-args'

const argv = (configured: readonly string[], fullAccess = false): string[] =>
grokAgentArgv({ fullAccess, configured })

describe('grokAgentArgv', () => {
it('keeps the root options `grok agent` reads before `agent`, as typed', () => {
expect(
argv([
'--debug',
'--debug-file',
'/tmp/grok.log',
'--leader-socket=/tmp/leader.sock',
'--disable-web-search',
'--no-auto-update',
'--compaction-mode=auto',
'--compaction-detail',
'brief',
'--log-sampling'
])
).toEqual([
'--debug',
'--debug-file',
'/tmp/grok.log',
'--leader-socket=/tmp/leader.sock',
'--disable-web-search',
'--no-auto-update',
'--compaction-mode=auto',
'--compaction-detail',
'brief',
'--log-sampling',
'agent',
'stdio'
])
})

// `grok agent` ignores the root model, effort and leader options; its own take them.
it('moves model, effort and --no-leader to the agent options', () => {
expect(argv(['-mgrok-4', '--effort', 'high', '--reasoning-effort=low', '--no-leader'])).toEqual(
[
'agent',
'--model=grok-4',
'--reasoning-effort',
'high',
'--reasoning-effort=low',
'--no-leader',
'stdio'
]
)
expect(argv(['--model', 'grok-4'], true)).toEqual([
'agent',
'--model',
'grok-4',
'--always-approve',
'stdio'
])
})

// Grok's parser reads a separate `-x` as an option, never a value; joined, it's the value.
it('keeps a joined value joined, so one that starts with a dash stays a value', () => {
expect(argv(['--debug-file=-grok.log', '-m-x', '--compaction-mode=-y'])).toEqual([
'--debug-file=-grok.log',
'--compaction-mode=-y',
'agent',
'--model=-x',
'stdio'
])
})

it('drops permission, terminal-only and session options with their values', () => {
expect(
argv([
'--permission-mode',
'bypassPermissions',
'--always-approve',
'--yolo',
'--dangerously-skip-permissions',
'--oauth',
'--force-login',
'--allow',
'Bash(git:*)',
'--allowedTools=Edit',
'--deny',
'Bash(rm:*)',
'--disallowedTools',
'Write',
'--sandbox',
'strict',
'--trust',
'--no-alt-screen',
'--minimal',
'--fullscreen',
'-h',
'-V',
'--resume',
'abc',
'-r',
'-c',
'--session-id=s1',
'-s',
's2',
'--fork-session',
'--load',
's3',
'-p',
'hello',
'--output-format',
'json',
'--verbatim'
])
).toEqual(['agent', 'stdio'])
})

it.each([
{ configured: ['fix the private tests'], option: 'prompt', problem: 'positionalPrompt' },
{ configured: ['--debug', '--', 'private'], option: 'prompt', problem: 'positionalPrompt' },
{ configured: ['-'], option: 'prompt', problem: 'positionalPrompt' },
{ configured: ['--cwd', '/private'], option: '--cwd', problem: 'unsupportedOption' },
{ configured: ['-w'], option: '-w', problem: 'unsupportedOption' },
{ configured: ['--worktree=private'], option: '--worktree', problem: 'unsupportedOption' },
{ configured: ['--ref', 'private'], option: '--ref', problem: 'unsupportedOption' },
{ configured: ['--leader'], option: '--leader', problem: 'unsupportedOption' },
{ configured: ['--rules', 'private'], option: '--rules', problem: 'unsupportedOption' },
{
configured: ['--system-prompt', 'private'],
option: '--system-prompt',
problem: 'unsupportedOption'
},
{ configured: ['--tools', 'private'], option: '--tools', problem: 'unsupportedOption' },
{ configured: ['--future=private'], option: '--future', problem: 'unsupportedOption' },
{ configured: ['--debug=private'], option: '--debug', problem: 'unsupportedOption' },
{ configured: ['-m', 'a', '--model', 'private'], option: '--model', problem: 'multipleValues' },
{ configured: ['-m'], option: '-m', problem: 'missingValue' },
{ configured: ['--debug-file', '--debug'], option: '--debug-file', problem: 'missingValue' }
] as const)('refuses what a chat cannot honor: %j', ({ configured, option, problem }) => {
const thrown = () => argv(configured)
expect(thrown).toThrow(StructuredAgentArgumentsError)
try {
thrown()
} catch (error) {
expect(error).toMatchObject({ argumentProblem: { agent: 'Grok', option, problem } })
for (const text of [JSON.stringify(error), String(error)]) {
expect(text).not.toContain('private')
}
}
})
})
194 changes: 194 additions & 0 deletions src/main/acp/acp-dialects/grok-launch-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Saved Arguments are written for the interactive `grok` a terminal runs; a chat runs
// `grok [root options] agent [agent options] stdio`, and `agent` reads only a few root options. So
// each option either stays before `agent`, moves to the `agent` option it has there, is dropped
// because it sets permissions, belongs to a terminal or to the session Orca owns, or refuses the
// start by name because a chat can't honor it.
import { StructuredAgentArgumentsError } from '../../native-chat/structured-agent-arguments-error'

type LaunchPlan = { root: string[]; agent: string[]; models: number }

type Apply = (plan: LaunchPlan, option: string, value: string, joined: boolean) => void

type OptionRule = { takesValue: boolean; apply?: Apply } | 'refuse'

/** A value written joined (`--opt=value`) stays joined: Grok's parser takes a separate value only
* when it doesn't start with `-`. */
function spelled(name: string, value: string, joined: boolean): string[] {
if (!value) {
return [name]
}
return joined ? [`${name}=${value}`] : [name, value]
}

const root: Apply = (plan, option, value, joined) => {
plan.root.push(...spelled(option, value, joined))
}

const agentOption =
(name: string): Apply =>
(plan, _option, value, joined) => {
plan.agent.push(...spelled(name, value, joined))
}

const model: Apply = (plan, option, value, joined) => {
// `agent --model` takes one value; a second is the parser's error in a terminal too.
if (++plan.models > 1) {
throw new StructuredAgentArgumentsError('Grok', option, 'multipleValues')
}
agentOption('--model')(plan, option, value, joined)
}

const VALUE = { takesValue: true }
const SWITCH = { takesValue: false }
const ROOT_VALUE = { takesValue: true, apply: root }
const ROOT_SWITCH = { takesValue: false, apply: root }
const EFFORT = { takesValue: true, apply: agentOption('--reasoning-effort') }

// From grok's own parser (xai-grok-pager src/app/cli.rs); `grok agent` honors only the root
// options kept here (xai-grok-pager-bin src/main.rs: run_agent_command, and configure_process_env,
// which sets the debug, leader-socket, compaction and log-sampling environment before dispatch).
const RULES: Record<string, OptionRule> = {
'--debug': ROOT_SWITCH,
'--debug-file': ROOT_VALUE,
'--leader-socket': ROOT_VALUE,
'--disable-web-search': ROOT_SWITCH,
'--no-auto-update': ROOT_SWITCH,
'--compaction-mode': ROOT_VALUE,
'--compaction-detail': ROOT_VALUE,
'--log-sampling': ROOT_SWITCH,
'-m': { ...VALUE, apply: model },
'--model': { ...VALUE, apply: model },
'--effort': EFFORT,
'--reasoning-effort': EFFORT,
// A root `--no-leader` is an error beside `agent`; the agent takes its own.
'--no-leader': { takesValue: false, apply: agentOption('--no-leader') },
// A typed permission mode never applies. Full access is Agent Permissions' bypass flag, which it
// keeps in these Arguments and reads on its own; otherwise Grok's own config decides (STA-9763).
'--permission-mode': VALUE,
'--always-approve': SWITCH,
'--yolo': SWITCH,
'--dangerously-skip-permissions': SWITCH,
'--allow': VALUE,
'--allowedTools': VALUE,
'--deny': VALUE,
'--disallowedTools': VALUE,
'--sandbox': VALUE,
'--trust': SWITCH,
'--trust-folder': SWITCH,
// Only shape a terminal.
'--no-alt-screen': SWITCH,
'--minimal': SWITCH,
'--fullscreen': SWITCH,
'-h': SWITCH,
'--help': SWITCH,
'-v': SWITCH,
'-V': SWITCH,
'--version': SWITCH,
// The terminal's own sign-in screen; `grok agent` never reads them.
'--oauth': SWITCH,
'--force-login': SWITCH,
// The session and its output are the chat's own.
'-r': { takesValue: false },
'--resume': { takesValue: false },
'--load': VALUE,
'-c': SWITCH,
'--continue': SWITCH,
'-s': VALUE,
'--session-id': VALUE,
'--fork-session': SWITCH,
'--restore-code': SWITCH,
'-p': VALUE,
'--single': VALUE,
'--print': VALUE,
'--prompt-json': VALUE,
'--prompt-file': VALUE,
'--output-format': VALUE,
'--include-partial-messages': SWITCH,
'--json-schema': VALUE,
'--verbatim': SWITCH,
// No chat equivalent: another folder, a shared process, or another prompt or tool set.
'--cwd': 'refuse',
'-w': 'refuse',
'--worktree': 'refuse',
'--worktree-ref': 'refuse',
'--ref': 'refuse',
'--leader': 'refuse',
'--rules': 'refuse',
'--append-system-prompt': 'refuse',
'--system-prompt': 'refuse',
'--system-prompt-override': 'refuse',
'--tools': 'refuse'
}

/** Takes an optional value, as `--resume [SESSION]` does: only a next token that isn't an option. */
const OPTIONAL_VALUE = new Set(['-r', '--resume'])

function optionName(token: string): string {
if (token.startsWith('--')) {
return token.split('=', 1)[0]
}
return /^-[mspr].+/.test(token) ? token.slice(0, 2) : token
}

// Grok's parser accepts `-m=X` as well as `-mX`.
function inlineValue(token: string, option: string): string | undefined {
if (token === option) {
return undefined
}
return token.startsWith('--') ? token.slice(option.length + 1) : token.slice(2).replace(/^=/, '')
}

/** Saved terminal Arguments as `grok` argv: what goes before `agent`, and the `agent` options. */
function grokLaunchArgs(tokens: readonly string[]): { root: string[]; agent: string[] } {
const plan: LaunchPlan = { root: [], agent: [], models: 0 }
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index]
if (token === '--') {
// What follows is the terminal's first prompt.
if (index + 1 < tokens.length) {
throw new StructuredAgentArgumentsError('Grok', token, 'positionalPrompt')
}
break
}
if (!token.startsWith('-') || token === '-') {
throw new StructuredAgentArgumentsError('Grok', token, 'positionalPrompt')
}
const option = optionName(token)
const rule = RULES[option]
if (!rule || rule === 'refuse') {
throw new StructuredAgentArgumentsError('Grok', option, 'unsupportedOption')
}
let value = ''
let joined = false
if (rule.takesValue) {
const inline = inlineValue(token, option)
joined = inline !== undefined
value = inline ?? tokens[++index] ?? ''
if (!value || value === '--' || (value.startsWith('-') && inline === undefined)) {
throw new StructuredAgentArgumentsError('Grok', option, 'missingValue')
}
} else if (OPTIONAL_VALUE.has(option)) {
if (
token === option &&
tokens[index + 1] !== undefined &&
!tokens[index + 1].startsWith('-')
) {
index += 1
}
} else if (token !== option) {
throw new StructuredAgentArgumentsError('Grok', option, 'unsupportedOption')
}
rule.apply?.(plan, option, value, joined)
}
return { root: plan.root, agent: plan.agent }
}

/** `grok [root] agent [agent] stdio` for a chat: the saved Arguments placed where `grok agent`
* reads them, and full access as the agent's own flag. */
export function grokAgentArgv(input: {
fullAccess: boolean
configured: readonly string[]
}): string[] {
const { root, agent } = grokLaunchArgs(input.configured)
return [...root, 'agent', ...agent, ...(input.fullAccess ? ['--always-approve'] : []), 'stdio']
}
8 changes: 5 additions & 3 deletions src/main/acp/acp-launch-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys'
import type { AcpDialect } from './acp-dialects/acp-dialect'
import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect'
import { grokAgentArgv } from './acp-dialects/grok-launch-args'

export type AcpLaunchSpec = {
/** The Orca agent id (a `TuiAgent`), which names the agent's records and its catalog label. */
agent: string
command: string
/** Built per launch: `fullAccess` is the Agent Permissions setting's bypass posture. */
args(input: { fullAccess: boolean }): string[]
/** Built per launch: `fullAccess` is the Agent Permissions setting's bypass posture, and
* `configured` the saved Arguments as typed. */
args(input: { fullAccess: boolean; configured: readonly string[] }): string[]
/** Overlaid on the child's environment. */
env: Readonly<Record<string, string>>
dialect: AcpDialect
Expand All @@ -39,7 +41,7 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = {
agent: 'grok',
command: 'grok',
// `--always-approve` only for full access, as the user's setting chooses.
args: ({ fullAccess }) => ['agent', ...(fullAccess ? ['--always-approve'] : []), 'stdio'],
args: grokAgentArgv,
env: {},
dialect: GROK_ACP_DIALECT,
loginCommand: ['grok', 'login'],
Expand Down
2 changes: 1 addition & 1 deletion src/main/acp/acp-structured-adapter.test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export async function openAcpAdapterRig(
resolveLaunch: async () => ({
spec: GROK,
command: '/opt/grok/bin/grok',
args: GROK.args({ fullAccess: false }),
args: GROK.args({ fullAccess: false, configured: [] }),
cwd: '/workspace/project',
env: { PATH: '/usr/bin', ORCA_PANE_KEY: 'tab-1:pane-1', ORCA_AGENT_HOOK_PORT: '1234' },
fullAccess: false,
Expand Down
1 change: 1 addition & 0 deletions src/main/acp/acp-structured-host-restore-failed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ async function openRestoreRig(code: number, message: string) {
readJournal: (sessionId) => replayJournal(journalDatabase.db, sessionId),
resolveWorkspacePath: async () => '/workspace',
resolveEnvironment: async () => ({ PATH: '/usr/bin' }),
resolveLaunchArgs: () => [],
resolveCommand: () => '/fake/grok'
})
const warnings = async () => {
Expand Down
Loading
Loading