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
7 changes: 7 additions & 0 deletions packages/sdk/src/authored-worker-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,19 @@ export function authoredWorkerRunner(
`f.agent options.model must be a string when set (got ${typeof options.model}).`,
);
}
if (options.cwd !== undefined && typeof options.cwd !== 'string') {
throw new AuthoredFlowExecutionError(
'agent_cli_unresolved',
`f.agent options.cwd must be a string when set (got ${typeof options.cwd}).`,
);
}
const output = await run({
id, type: 'agent', instruction: options.task,
...(localAgentStream === undefined ? {} : { surfaces: { streams: [{ stream: localAgentStream }] } }),
...(options.workspace === undefined ? {} : { surfaces: { workspace: [{ surface: options.workspace }] } }),
...(options.cli === undefined ? {} : { cli: options.cli }),
...(options.model === undefined ? {} : { model: options.model }),
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
});
if (typeof output !== 'object' || output === null || Array.isArray(output)) {
throw new AuthoredFlowExecutionError('journal_protocol_violation', `step "${id}" produced a non-object output`);
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ function compileStep(step: StepSpec): StepSpec {
...(s.agent !== undefined ? { agent: s.agent } : {}),
...(s.cli !== undefined ? { cli: s.cli } : {}),
...(s.model !== undefined ? { model: s.model } : {}),
...(s.cwd !== undefined ? { cwd: s.cwd } : {}),
Comment thread
cursor[bot] marked this conversation as resolved.
recoveryMode,
...(s.surfaces !== undefined ? { surfaces: s.surfaces } : {}),
...(s.permissions !== undefined ? { permissions: s.permissions } : {}),
Expand Down Expand Up @@ -622,6 +623,7 @@ function toKernelStep(step: StepSpec): KernelStepSpec {
instruction: step.instruction,
...(step.cli !== undefined ? { cli: step.cli } : {}),
...(step.model !== undefined ? { model: step.model } : {}),
...(step.cwd !== undefined ? { cwd: step.cwd } : {}),
Comment thread
cursor[bot] marked this conversation as resolved.
recovery_mode: step.recoveryMode ?? 'reset',
};
const surfaces = {
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/src/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ export interface AgentStepSpec extends BaseStepSpec {
surfaces?: AgentSurfaces;
recoveryMode?: RecoveryMode;
permissions?: PermissionsSpec;
/** Working directory for the CLI subprocess; defaults to the flow-runner's cwd. */
cwd?: string;
/**
* Structured-output authoring sugar. A successful CLI JSON object is the parsed
* value; the kernel persists it only after `json_schema` verification.
Expand Down Expand Up @@ -432,6 +434,8 @@ export interface KernelAgentStep extends KernelStepCommon {
recovery_mode: RecoveryMode;
surfaces?: KernelAgentSurfaces;
permissions?: KernelPermissionsSpec;
/** Working directory for the CLI subprocess; kernel passes through untouched. */
cwd?: string;
}

export type KernelStepSpec = KernelDeterministicStep | KernelLlmStep | KernelAgentStep;
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/step-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,5 @@ export const STEP_COMMON_FIELDS = [
export const STEP_FIELDS_BY_TYPE = {
deterministic: ['command', 'timeoutMs', 'lease_ms'],
llm: ['prompt', 'model', 'cli', 'output'],
agent: ['instruction', 'agent', 'cli', 'model', 'surfaces', 'recoveryMode', 'permissions', 'output'],
agent: ['instruction', 'agent', 'cli', 'model', 'cwd', 'surfaces', 'recoveryMode', 'permissions', 'output'],
} as const satisfies Record<StepType, readonly string[]>;
5 changes: 4 additions & 1 deletion packages/sdk/src/worker-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function runAgentCli(
signal?: AbortSignal,
mode: 'agent' | 'llm' = 'agent',
sidechannel?: SidechannelContext,
cwd?: string,
Comment thread
cursor[bot] marked this conversation as resolved.
): Promise<WorkerCliResult> {
signal?.throwIfAborted();
if (signal !== undefined && process.platform === 'win32') {
Expand Down Expand Up @@ -81,7 +82,7 @@ export async function runAgentCli(
// Structured provider output carries the authoritative token counts.
const args = [...invocation.args];
args.splice(args.length - 1, 0, ...(kind === 'claude' ? ['--output-format', 'json'] : ['--json']));
return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel), kind), model);
return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd), kind), model);
}

async function spawnInvocation(
Expand All @@ -90,6 +91,7 @@ async function spawnInvocation(
env: NodeJS.ProcessEnv,
signal?: AbortSignal,
sidechannel?: SidechannelContext,
cwd?: string,
): Promise<WorkerCliResult> {
let writeInput: (bytes: Buffer) => Promise<boolean> = async () => false;
let canDrive = () => false;
Expand All @@ -104,6 +106,7 @@ async function spawnInvocation(
const child = spawn(cli, invocation.args, {
stdio: ['pipe', 'pipe', 'pipe'], env,
detached: ownsGroup,
...(cwd === undefined ? {} : { cwd }),
});
child.stdin.on('error', () => {});
if (channel === undefined) child.stdin.end();
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export class AgentWorker extends EventEmitter {
? runAgentCli(spec.cli, workerInstruction(spec.instruction, dispatch), dispatch.wake_context, spec.model, undefined, signal, 'agent', this.options.dataDir === undefined ? undefined : {
dataDir: this.options.dataDir, runId: dispatch.run_id, stepId: dispatch.step_id,
onReady: this.options.onPtyReady, onDrive: () => { humanIntervention = true; },
})
}, typeof spec.cwd === 'string' ? spec.cwd : undefined)
: Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' }));
const { result, usage } = workerSpend(completed, spec.model);
const completionReason = result.exit_code === 0 ? 'success' : 'worker_error';
Expand Down
5 changes: 4 additions & 1 deletion packages/sdk/tests/verb-field-lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const VERB_FIELD_VALUES: Record<string, unknown> = {
recoveryMode: 'reset',
permissions: { accessPreset: 'readonly' },
output: { type: 'object' },
cwd: '/tmp/foreign-cwd',
};

/**
Expand Down Expand Up @@ -194,7 +195,7 @@ describe('closed per-verb step fields', () => {
expect(STEP_FIELDS_BY_TYPE).toEqual({
deterministic: ['command', 'timeoutMs', 'lease_ms'],
llm: ['prompt', 'model', 'cli', 'output'],
agent: ['instruction', 'agent', 'cli', 'model', 'surfaces', 'recoveryMode', 'permissions', 'output'],
agent: ['instruction', 'agent', 'cli', 'model', 'cwd', 'surfaces', 'recoveryMode', 'permissions', 'output'],
});
expect(CROSS_VERB_STEP_FIELDS.map(({ label }) => label).sort()).toEqual([
'agent foreign command',
Expand All @@ -203,6 +204,7 @@ describe('closed per-verb step fields', () => {
'agent foreign timeoutMs',
'deterministic foreign agent',
'deterministic foreign cli',
'deterministic foreign cwd',
'deterministic foreign instruction',
'deterministic foreign model',
'deterministic foreign output',
Expand All @@ -212,6 +214,7 @@ describe('closed per-verb step fields', () => {
'deterministic foreign surfaces',
'llm foreign agent',
'llm foreign command',
'llm foreign cwd',
'llm foreign instruction',
'llm foreign lease_ms',
'llm foreign permissions',
Expand Down
50 changes: 50 additions & 0 deletions packages/sdk/tests/worker-cli-cwd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { EventEmitter } from 'node:events';
import { describe, expect, it, vi } from 'vitest';

const spawnCalls: Array<{ cli: string; args: string[]; options: Record<string, unknown> }> = [];

vi.mock('node:child_process', () => ({
spawn: (cli: string, args: string[], options: Record<string, unknown>) => {
spawnCalls.push({ cli, args, options });
const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
const stdin = new EventEmitter() as EventEmitter & Record<string, unknown>;
stdin.end = () => {};
stdin.write = (_: unknown, cb: (e?: Error) => void) => { cb(); return true; };
stdin.destroyed = false;
stdin.writableEnded = false;
const stdout = new EventEmitter();
const stderr = new EventEmitter();
child.stdin = stdin;
child.stdout = stdout;
child.stderr = stderr;
child.kill = () => true;
setImmediate(() => {
const payload = cli === 'claude'
? JSON.stringify({ result: '', usage: { input_tokens: 1, output_tokens: 1 }, total_cost_usd: 0 })
: JSON.stringify({ type: 'usage', usage: { input_tokens: 1, output_tokens: 1, total_cost_usd: 0 } });
stdout.emit('data', Buffer.from(payload));
child.emit('close', 0);
});
return child as unknown as ReturnType<typeof import('node:child_process').spawn>;
},
}));

// Import after the mock is registered so the module picks up the mocked spawn.
import { runAgentCli } from '../src/worker-cli.js';

describe('runAgentCli — cwd propagation (flows#357)', () => {
it('threads explicit cwd into spawn options', async () => {
spawnCalls.length = 0;
await runAgentCli('claude', 'hello', undefined, 'claude-sonnet-4-6',
undefined, undefined, 'agent', undefined, '/tmp/probe-worktree');
expect(spawnCalls).toHaveLength(1);
expect(spawnCalls[0]?.options.cwd).toBe('/tmp/probe-worktree');
});

it('omits cwd when not provided (inherits parent cwd)', async () => {
spawnCalls.length = 0;
await runAgentCli('claude', 'hello', undefined, 'claude-sonnet-4-6');
expect(spawnCalls).toHaveLength(1);
expect(spawnCalls[0]?.options.cwd).toBeUndefined();
});
});
3 changes: 2 additions & 1 deletion packages/sdk/tsconfig.tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
"tests/fixtures/needs-human.flow.ts",
"tests/named-gates.test.ts",
"tests/build-gate.test.ts",
"tests/scope-preflight.test.ts"
"tests/scope-preflight.test.ts",
"tests/worker-cli-cwd.test.ts"
],
"exclude": ["node_modules", "dist"]
}
2 changes: 2 additions & 0 deletions packages/surface/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface AgentOptions {
workspace?: string;
cli?: string;
model?: string;
/** Working directory for the CLI subprocess; defaults to the flow-runner's cwd. */
cwd?: string;
}

export interface LlmOptions {
Expand Down
Loading