Skip to content

Commit e6f2292

Browse files
committed
Gate --effort emission on per-model validity in Runner
Route normalization already enforces this on PATCH, but Runner is public and constructed directly in tests/future call sites. Move the "haiku gets no --effort, sonnet rejects xhigh" invariant down to buildArgs so it survives any caller. When an invalid pair slips through, log a warning and spawn without the flag. Adds smoke coverage for haiku+xhigh and sonnet+xhigh. Addresses Copilot review on PR #27. https://claude.ai/code/session_015mACScqpz9U8NmhpDorPhA
1 parent 2d740c8 commit e6f2292

2 files changed

Lines changed: 80 additions & 1 deletion

File tree

src/server/runner.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
DEFAULT_WALLCLOCK_CAP_MS,
1515
READ_ONLY_TOOLS,
1616
WRITE_TOOLS,
17+
effortLevelsForModel,
1718
type Effort,
1819
} from '@shared/constants.js';
1920
import type { Mode } from '@shared/schemas/types.js';
@@ -423,8 +424,18 @@ export class Runner extends EventEmitter {
423424
'project',
424425
'--disable-slash-commands',
425426
];
426-
if (this.input.effort) {
427+
// Last line of defence: only emit --effort if the value is valid for the
428+
// model. The route layer already normalizes on PATCH, but Runner can be
429+
// constructed directly (tests, future code paths), so the invariant
430+
// "haiku never gets --effort, sonnet never gets xhigh" lives here too.
431+
if (this.input.effort && effortLevelsForModel(this.input.model).includes(this.input.effort)) {
427432
args.push('--effort', this.input.effort);
433+
} else if (this.input.effort) {
434+
log.warn('runner.effort-dropped', {
435+
model: this.input.model,
436+
effort: this.input.effort,
437+
hint: 'effort value not supported by model; spawning without --effort',
438+
});
428439
}
429440
return args;
430441
}

test/runner.smoke.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,4 +1011,72 @@ await scenario('effort flag: omitted when null (haiku case)', async () => {
10111011
});
10121012
});
10131013

1014+
await scenario('effort flag: dropped when model rejects the value (haiku + xhigh)', async () => {
1015+
await withSandbox(
1016+
'effort-haiku-xhigh',
1017+
async ({ runDir, projectDir, outputsDir, initialConfig }) => {
1018+
const dumpPath = join(runDir, 'argv.txt');
1019+
// Construct a malformed input: route normalization should have caught
1020+
// this, but Runner must defend itself when invoked directly.
1021+
initialConfig.effort = 'xhigh';
1022+
const runner = new Runner({
1023+
claudeBin: fakeBin,
1024+
projectDir,
1025+
runDir,
1026+
outputsDir,
1027+
prompt: 'go',
1028+
model: 'haiku',
1029+
effort: 'xhigh',
1030+
mode: 'read-only',
1031+
initialConfig,
1032+
env: {
1033+
...process.env,
1034+
FAKE_CLAUDE_SCENARIO: 'happy',
1035+
FAKE_CLAUDE_DUMP_ARGS: dumpPath,
1036+
},
1037+
});
1038+
await runner.start();
1039+
const final = await runner.wait();
1040+
if (final.status !== 'completed') throw new Error(`expected completed, got ${final.status}`);
1041+
const argv = (await readFile(dumpPath, 'utf8')).split('\n');
1042+
if (argv.includes('--effort')) {
1043+
throw new Error(`expected no --effort for haiku, got: ${argv.join(' ')}`);
1044+
}
1045+
},
1046+
);
1047+
});
1048+
1049+
await scenario('effort flag: dropped when model rejects the value (sonnet + xhigh)', async () => {
1050+
await withSandbox(
1051+
'effort-sonnet-xhigh',
1052+
async ({ runDir, projectDir, outputsDir, initialConfig }) => {
1053+
const dumpPath = join(runDir, 'argv.txt');
1054+
initialConfig.effort = 'xhigh';
1055+
const runner = new Runner({
1056+
claudeBin: fakeBin,
1057+
projectDir,
1058+
runDir,
1059+
outputsDir,
1060+
prompt: 'go',
1061+
model: 'sonnet',
1062+
effort: 'xhigh', // xhigh is opus-only
1063+
mode: 'read-only',
1064+
initialConfig,
1065+
env: {
1066+
...process.env,
1067+
FAKE_CLAUDE_SCENARIO: 'happy',
1068+
FAKE_CLAUDE_DUMP_ARGS: dumpPath,
1069+
},
1070+
});
1071+
await runner.start();
1072+
const final = await runner.wait();
1073+
if (final.status !== 'completed') throw new Error(`expected completed, got ${final.status}`);
1074+
const argv = (await readFile(dumpPath, 'utf8')).split('\n');
1075+
if (argv.includes('--effort')) {
1076+
throw new Error(`expected no --effort for sonnet+xhigh, got: ${argv.join(' ')}`);
1077+
}
1078+
},
1079+
);
1080+
});
1081+
10141082
console.log('\nAll runner smoke scenarios passed.');

0 commit comments

Comments
 (0)