feat(#3653): add review.models.cursor — wire modelArg/modelConfigKey for the cursor reviewer lane (#4160)

* feat(#3653): add review.models.cursor — wire modelArg/modelConfigKey for the cursor reviewer lane

cursor-agent exposes --model (204 selectable models) but the cursor lane
declared modelArg: null / modelConfigKey: null, so review.models.cursor
was rejected as an unknown config key and the #1517 reviewer-instances
escape hatch silently discarded a configured model at modelExpansion.

Wire the lane the same way codex already is: inject {{model}} into args
right after -p, set modelArg to --model, and declare modelConfigKey as
review.models.cursor plus its config schema entry. An unconfigured lane
still invokes byte-identically to today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3653): add changeset for review.models.cursor

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3653): update co-change surfaces that assumed cursor has no model key

gsd-test surfaced three surfaces still hardcoding "cursor declares no
modelConfigKey", broken by wiring review.models.cursor:

- tests/reviewer-config-federation.test.cjs: the #3691-narrows-#2797
  invariant test listed cursor among lanes that must own no model key.
- tests/settings-integrations.test.cjs: the #3651 keyless-lane test
  listed cursor as keyless, including a live config-set assertion that
  now correctly succeeds instead of failing (swapped to qwen).
- gsd-core/workflows/settings-integrations.md: the settable-keys
  enumeration and two prose call-outs still named cursor as keyless.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#3653): acknowledge deliberate growth of settings-integrations.md

settings-integrations.md grew 4 bytes because it now enumerates
review.models.cursor as a settable key alongside the other reviewer
lanes, matching the modelConfigKey wired for cursor in this PR.

Emitted-Drift-Ack-Growth: settings-integrations.md — adds review.models.cursor to the settable-keys enumeration and removes cursor from the two keyless-lane call-outs, matching #3653's modelConfigKey change

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#3653): fix malformed Emitted-Drift-Ack-Growth trailer

The previous commit's trailer was separated from Co-Authored-By by a
blank line, splitting it into an earlier, non-trailer paragraph — git's
trailer parser only recognizes the last contiguous block. Restating it
here immediately adjacent to Co-Authored-By so both parse as trailers.

Emitted-Drift-Ack-Growth: settings-integrations.md — adds review.models.cursor to the settable-keys enumeration and removes cursor from the two keyless-lane call-outs, matching #3653's modelConfigKey change
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3653): backfill changeset pr number

pr:0 -> pr:4160 now that https://github.com/open-gsd/gsd-core/pull/4160 exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-01 21:40:20 -04:00
committed by GitHub
parent ddb877fa0a
commit ff0361071d
9 changed files with 97 additions and 35 deletions

View File

@@ -46,6 +46,7 @@ const FULL_CONFIG = {
'review.models.claude': 'C',
'review.models.codex': 'X',
'review.models.opencode': 'O',
'review.models.cursor': 'U',
'review.models.agy': 'A',
'review.models.kimi-code': 'K',
'review.models.ollama': 'M',
@@ -85,7 +86,7 @@ const GOLDEN = [
{ slug: 'coderabbit', binary: 'coderabbit', argv: ['review', '--prompt-only'], stdin: false, out: 'stdout', timeout: 360000 },
{ slug: 'opencode', binary: 'opencode', argv: ['run', '--model', 'O', '--effort', 'high', '--format', 'json', '-'], stdin: true, out: 'stdout', timeout: 660000 },
{ slug: 'qwen', binary: 'qwen', argv: ['-'], stdin: true, out: 'stdout', timeout: 900000 },
{ slug: 'cursor', binary: 'cursor-agent', argv: ['-p', '--mode', 'ask', '--trust', '--output-format', 'text', FILE_REF], stdin: false, out: 'stdout', timeout: 900000 },
{ slug: 'cursor', binary: 'cursor-agent', argv: ['-p', '--model', 'U', '--mode', 'ask', '--trust', '--output-format', 'text', FILE_REF], stdin: false, out: 'stdout', timeout: 900000 },
// resolveLanePlan fully resolves {{nativeTimeout}} itself (#3274) — this row proves the
// unconfigured default reproduces the original literal exactly.
{ slug: 'antigravity', binary: 'agy', argv: ['--print-timeout', '540s', '--model', 'A', '-p', FILE_REF], stdin: false, out: 'stdout', timeout: 600000 },
@@ -277,14 +278,35 @@ describe('reviewer lane invocation — model resolution', () => {
});
test('a lane declaring no model key emits no model argument', () => {
for (const slug of ['qwen', 'cursor', 'coderabbit']) {
for (const slug of ['qwen', 'coderabbit']) {
const lane = REVIEWER_LANES.find((l) => l.slug === slug);
assert.equal(lane.modelConfigKey, null, `${slug} should declare no model key`);
const r = resolve(slug, { config: { 'review.models.qwen': 'X', 'review.models.cursor': 'X' } });
const r = resolve(slug, { config: { 'review.models.qwen': 'X' } });
assert.ok(!r.plan.argv.includes('X'));
}
});
test('cursor now declares a model key and arg (#3653) — no longer null', () => {
const lane = REVIEWER_LANES.find((l) => l.slug === 'cursor');
assert.equal(lane.modelConfigKey, 'review.models.cursor');
assert.equal(lane.invoke.modelArg, '--model');
});
test('an unconfigured cursor lane invokes exactly as it does today (#3653, byte-identical)', () => {
const r = resolve('cursor', { config: {} });
assert.deepStrictEqual(
r.plan.argv,
['-p', '--mode', 'ask', '--trust', '--output-format', 'text', r.plan.argv[r.plan.argv.length - 1]],
);
});
test('a configured review.models.cursor reaches --model, positioned right after -p (#3653)', () => {
const r = resolve('cursor', { config: { 'review.models.cursor': 'cursor-grok-4.5-high' } });
const i = r.plan.argv.indexOf('-p');
assert.equal(r.plan.argv[i + 1], '--model');
assert.equal(r.plan.argv[i + 2], 'cursor-grok-4.5-high');
});
test('unset, empty, whitespace and the literal string "null" all mean unconfigured', () => {
// `"null"` is the four literal characters `config-get --raw` prints for a missing key — every
// bash leg tested for it. A config written by an older workflow can still contain it.

View File

@@ -102,17 +102,20 @@ describe('reviewer config federation — provenance actually moved (#2797)', ()
});
test('a lane with no model flag and no host owns no MODEL or HOST key (#3691 narrows #2797)', () => {
// Absent-safe (ADR-2782 D4): qwen, cursor and coderabbit take neither a model
// Absent-safe (ADR-2782 D4): qwen and coderabbit take neither a model
// argument nor a host. Under #2797 that meant "declares nothing" — the only
// way a lane owned a config key was via a model flag or a host. #3691 gave
// every CLI lane a `review.max_prompt_tokens_per_reviewer.<slug>` key, a
// third legitimate reason to own a key, so qwen/cursor/coderabbit now
// legitimately own their own budget key. The part of the #2797 invariant
// that still holds — a lane must never own a MODEL or HOST key, or another
// lane's budget key, it has no use for — is what this asserts directly.
// third legitimate reason to own a key, so qwen/coderabbit now legitimately
// own their own budget key. `cursor` gained a real model flag (#3653) and
// now legitimately owns `review.models.cursor` too, so it is excluded from
// this loop. The part of the #2797 invariant that still holds — a lane must
// never own a MODEL or HOST key it has no use for, or another lane's budget
// key — is what this asserts directly for the two lanes that still have
// neither.
for (const [key, entry] of Object.entries(registry.configSchema || {})) {
const owner = entry && entry.owner;
if (!['qwen', 'cursor', 'coderabbit'].includes(owner)) continue;
if (!['qwen', 'coderabbit'].includes(owner)) continue;
assert.ok(
!key.startsWith('review.models.') && !key.endsWith('_host'),
`${owner} must not own a model or host key, but owns "${key}"`,

View File

@@ -211,7 +211,8 @@ describe('#3651 workflow — review.models settable-set rule', () => {
test('keyless reviewer lanes have no settable review.models key (#3651)', (t) => {
// Lanes whose capability declares modelConfigKey: null — the workflow used
// to walk users into writing these keys, and config-set rejects them.
for (const keyless of ['cursor', 'qwen', 'coderabbit']) {
// `cursor` gained a real modelConfigKey (#3653) and is no longer keyless.
for (const keyless of ['qwen', 'coderabbit']) {
assert.ok(
!isValidConfigKey(`review.models.${keyless}`),
`review.models.${keyless} must not validate (lane declares no modelConfigKey)`
@@ -227,7 +228,7 @@ describe('#3651 workflow — review.models settable-set rule', () => {
const tmp = createTempProject();
t.after(() => cleanup(tmp));
runGsdTools(['config-ensure-section'], tmp);
const r = runGsdTools(['config-set', 'review.models.cursor', 'cursor-model'], tmp);
const r = runGsdTools(['config-set', 'review.models.qwen', 'qwen-model'], tmp);
assert.ok(
!r.success,
'config-set must reject a keyless lane — the exact error the old workflow steered users into'