fix(research-loop-cap): floor the turn cap before the guard, not after

A fractional TREKRESEARCH_MAX_CONV_TURNS below 1 cleared the `n <= 0`
guard on its raw value and only then floored, so '0.5' and '0.9' became
0 and the budget became 0 x MAX_TOTAL_DIMENSIONS = 0: every turn denied,
the loop silently dead rather than bounded.

README.md:229 and docs/architecture.md:15 both promise that invalid
values fall back to 3. docs/command-modes.md:42 enumerated "empty,
non-numeric, zero, or negative" and happened to sidestep the case; the
enumeration is now exhaustive about it.

Measured before: '0.5' -> 0, '0.9' -> 0, '2.7' -> 2, '' / 'abc' / '-2'
/ '0' -> 3. Measured after: '0.5' -> 3, '0.9' -> 3, '2.7' -> 2, and
'Infinity' -> 3 (it is not a cap either).

A cap of 0 is not a narrower cap, it is an off switch. The tests pin
both directions: the fraction falls back, and allowTurn cannot report a
budget of 0 under it.

Review finding fc516799e6042e246a4b62d81903ac27c2efab84 (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
This commit is contained in:
Kjell Tore Guttormsen 2026-08-12 22:46:35 +02:00
commit def6c05384
3 changed files with 38 additions and 5 deletions

View file

@ -211,6 +211,32 @@ test('resolveMaxConvTurns — valid positive integer string is honored', () => {
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2' }), 2);
});
// A fractional value passed the `n <= 0` guard and only THEN floored, so 0.5 and
// 0.9 became 0 and the budget became 0 × 8 = 0 — every turn denied, the loop
// silently dead, while README.md and docs/architecture.md both promise a
// fallback of 3. The guard has to see the floored value, not the raw one.
test('resolveMaxConvTurns — a fractional value below 1 falls back to the default, never 0', () => {
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.5' }), MAX_CONV_TURNS);
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.9' }), MAX_CONV_TURNS);
});
test('resolveMaxConvTurns — a fractional value above 1 still floors (2.7 → 2)', () => {
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2.7' }), 2);
});
test('resolveMaxConvTurns — Infinity is not a cap and falls back to the default', () => {
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'Infinity' }), MAX_CONV_TURNS);
});
test('allowTurn — a fractional cap below 1 cannot produce a budget of 0', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '0.5' };
const r = allowTurn({ runId: 'r-frac', dimension: 'd1', effort: 'high' }, { env });
assert.equal(r.ok, true, 'a budget of 0 would make the loop silently dead, not bounded');
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
});
});
// ---- pure-core unit coverage --------------------------------------------------
test('isStormEnabled — only the literal string "1" enables', () => {