autoCompactWindow: per-profile bounded auto-compaction, both backends
CI / contract (pull_request) Successful in 41s
CI / build (pull_request) Failing after 1m37s

Add opt-in Integer autoCompactWindow to FleetConfig.Profile (last field,
null/unset = today's behaviour). Validated at config load to [100000,
1000000] — the band Claude Code's own --autocompact flag accepts.

Claude Code: appends --autocompact <window> to argv (mirrors --model),
so it survives the ccs <profile> wrapper.

opencode: has no absolute compact-at-N knob (only compaction.auto/prune/
reserved/tail_turns/preserve_recent_tokens), so the window is applied as
the resolved model's own limit.context (+ a required limit.output:16384
default) in the generated opencode.json, merged via get-or-create nodes
so it does not clobber a custom-provider block. Only applies when model:
resolves to "provider/model"; otherwise logs a WARN naming the profile
rather than silently doing nothing.

Docs added to fleetd.example.yaml explaining the cross-backend semantics
difference (compacts AT the window vs. WITHIN it).
This commit is contained in:
Dai Ha
2026-08-24 16:54:40 +02:00
parent 7655f1b51a
commit 2757bc7185
7 changed files with 340 additions and 9 deletions
+13
View File
@@ -140,6 +140,18 @@ herdrSocket: ~/.config/herdr/herdr.sock
# e.g. `env DISPLAY=:10.0 idea {dir}`. Best-effort: a failure is logged, never
# fails the spawn. Omit to open the member's module by hand. There is no close
# half yet — an opened module stays open until the operator closes it.
# autoCompactWindow → opt-in, default off. A bounded token window that forces a spawned member to
# compact its context instead of running on the backend's own default and dying
# mid-turn (losing its fleet_reply — the whole point of the turn — with it).
# Validated at config load to [100000, 1000000] — the band Claude Code's own
# --autocompact flag accepts.
# CROSS-BACKEND SEMANTICS DIFFER: on claude-code this is a launch-time
# `--autocompact <tokens>` flag — the member compacts AT this window. opencode
# has no equivalent flag (it only forces `compaction.auto: true`, unconditionally,
# already), so this is instead applied as the model's `limit.context` in the
# generated opencode.json — the member compacts WITHIN this window, not exactly
# at it — and only when this profile's `model:` is in `provider/model` form; if it
# isn't, bridged logs a WARN naming the profile rather than silently doing nothing.
# tokenEnv → host env var holding the worker's auth token (value never stored in config);
# omit for a backend that needs no token (e.g. a local ollama).
# cwd → pin this profile's working directory (CB-112). Omit to inherit the primary's
@@ -256,6 +268,7 @@ profiles:
# ideMcpUrl: http://127.0.0.1:29170/index-mcp/streamable-http # opt-in (CB-634): IDE code intelligence, pinned to the worktree
# ideProjectDir: bridged # CB-634: module dir the IDE opens + the overlay pins (this repo's pom is in bridged/)
# ideOpenCommand: env DISPLAY=:10.0 idea {dir} # CB-634 auto-open: opens {dir} in the IDE at spawn; omit to open by hand
# autoCompactWindow: 250000 # opt-in: bound member context; claude-code compacts AT this, opencode within it (model limit.context)
gx11: # a second backend, so `placement: weighted` has a choice
baseUrl: http://gx01.gw:8000 # self-hosted; ccs handles the model + token
placement: tab
@@ -275,6 +275,19 @@ public record FleetConfig(
* profile that does not opt in. Read live off the current config, so it is
* HOT: a change takes effect on the next exhaustion classification / spawn,
* no restart needed.
* @param autoCompactWindow opt-in per-profile token window that forces a spawned member to
* auto-compact its context at (Claude Code) or within (opencode) a bound the
* operator chooses, instead of the backend's own default. {@code null} (the
* default) leaves today's behaviour exactly — opencode already forces
* {@code compaction.auto: true} unconditionally (CB-523) but has no absolute
* window, and Claude Code has neither. When set, validated at config load to
* {@code [100000, 1000000]} — the band Claude Code's own {@code --autocompact
* <tokens>} flag accepts. The two backends honour it differently: Claude Code
* compacts AT this window (a launch-time {@code --autocompact} flag);
* opencode has no such knob, so this is applied as the model's
* {@code limit.context} instead, which bounds the window opencode compacts
* <em>within</em>, and only when {@code model} resolves to a
* {@code provider/model} pair.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Profile(String profile, String baseUrl, String model,
@@ -292,7 +305,8 @@ public record FleetConfig(
String credentialId,
String ideMcpUrl,
String ideProjectDir,
String ideOpenCommand) {
String ideOpenCommand,
Integer autoCompactWindow) {
/** Peer kind spawned by {@link dev.ltms.fleet.member.ClaudeCodeLauncher} (the default). */
public static final String KIND_CLAUDE_CODE = "claude-code";
@@ -362,6 +376,10 @@ public record FleetConfig(
// substituted; blank ⇒ no auto-open (the operator opens the module by hand).
ideProjectDir = (ideProjectDir == null || ideProjectDir.isBlank()) ? null : ideProjectDir;
ideOpenCommand = (ideOpenCommand == null || ideOpenCommand.isBlank()) ? null : ideOpenCommand;
// Opt-in per profile, default off (null). No clamping here — unlike ideMcpUrl/ideProjectDir
// there is no blank-string form to normalize (it's an Integer), and the [100000, 1000000]
// range is enforced eagerly at config load (rejectAutoCompactWindowOutOfRange), naming the
// profile, rather than silently clamped here. A profile that never sets it keeps null.
}
/**
@@ -406,7 +424,29 @@ public record FleetConfig(
public Profile withProfile(String p) {
return new Profile(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad, subscription,
exhaustedPattern, credentialId, ideMcpUrl, ideProjectDir, ideOpenCommand);
exhaustedPattern, credentialId, ideMcpUrl, ideProjectDir, ideOpenCommand, autoCompactWindow);
}
/**
* Backward-compatible constructor without the {@code autoCompactWindow} field — the profile
* leaves auto-compaction at the backend's own default (opencode's unconditional
* {@code compaction.auto: true}, or Claude Code's built-in threshold), exactly as before this
* key existed. This is the shape the canonical constructor had before the field was added —
* every pre-existing Java call site (and any YAML that omits the key) keeps compiling and
* behaving identically; Jackson binds the canonical (longest) constructor, so YAML omitting
* {@code autoCompactWindow:} still lands here as {@code null} via that path, not this one.
*/
public Profile(String profile, String baseUrl, String model,
String configDir, String tokenEnv, List<String> argv,
String placement, String workspace, String tabLabel, String mcpUrl,
String cwd, List<String> parityOverlay, String gitTokenEnv, String gitHostEnv,
String kind, Map<String, String> env, Float weight, Integer maxLoad,
Boolean subscription, String exhaustedPattern, String credentialId,
String ideMcpUrl, String ideProjectDir, String ideOpenCommand) {
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad,
subscription, exhaustedPattern, credentialId, ideMcpUrl, ideProjectDir, ideOpenCommand,
null);
}
/**
@@ -1195,6 +1235,7 @@ public record FleetConfig(
warnUnknownTopLevelKeys(yaml, path);
rejectDuplicateMemberSlots(yaml);
rejectNegativeMaxLoad(yaml);
rejectAutoCompactWindowOutOfRange(yaml);
rejectUnknownKind(yaml);
rejectUnknownAuthMode(yaml);
rejectUnknownPlacement(yaml);
@@ -1497,6 +1538,52 @@ public record FleetConfig(
}
}
/** Lowest {@code autoCompactWindow} Claude Code's {@code --autocompact <tokens>} flag accepts. */
static final int AUTO_COMPACT_WINDOW_MIN = 100_000;
/** Highest {@code autoCompactWindow} Claude Code's {@code --autocompact <tokens>} flag accepts. */
static final int AUTO_COMPACT_WINDOW_MAX = 1_000_000;
/**
* Reject a profile whose {@code autoCompactWindow:} is set but outside the token band Claude
* Code's own {@code --autocompact <tokens>} flag accepts (100k–1M), naming both the profile and
* the value.
*
* <p>Unset/{@code null} means "off" and passes silently — today's behaviour for every profile
* that does not opt in (see {@link Profile#autoCompactWindow()}). A profile that DOES set the key
* is validated eagerly, at config load, rather than failing later when Claude Code itself refuses
* the launch flag on spawn — the same "fail loud at load, not lazily at first spawn" reasoning as
* {@link #rejectNegativeMaxLoad} and {@link #rejectUnknownPlacementPolicy}.
*
* @param yaml the raw config text
* @throws IllegalStateException when any profile's {@code autoCompactWindow} is set and outside
* {@code [100000, 1000000]}
*/
static void rejectAutoCompactWindowOutOfRange(String yaml) {
Map<?, ?> raw;
try {
raw = YAML.readValue(yaml, Map.class);
} catch (IOException | IllegalArgumentException e) {
return; // a malformed file is reported by the real parse, not here
}
if (raw == null || !(raw.get("profiles") instanceof Map<?, ?> profiles)) {
return;
}
List<String> bad = profiles.entrySet().stream()
.filter(e -> e.getValue() instanceof Map<?, ?> p
&& p.get("autoCompactWindow") instanceof Number n
&& (n.doubleValue() < AUTO_COMPACT_WINDOW_MIN || n.doubleValue() > AUTO_COMPACT_WINDOW_MAX))
.map(e -> String.valueOf(e.getKey()))
.sorted()
.toList();
if (!bad.isEmpty()) {
throw new IllegalStateException("refusing to start: profile(s) [" + String.join(", ", bad)
+ "] set autoCompactWindow outside [" + AUTO_COMPACT_WINDOW_MIN + ", "
+ AUTO_COMPACT_WINDOW_MAX + "] — Claude Code's --autocompact flag accepts only "
+ "that band of tokens; omit the key to leave auto-compaction at each backend's "
+ "own default.");
}
}
/** The peer kinds this build has an adapter for — {@link Profile#kind()}'s only valid values. */
private static final Set<String> KNOWN_KINDS = Set.of(Profile.KIND_CLAUDE_CODE, Profile.KIND_OPENCODE);
@@ -248,7 +248,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
// has neither MCP nor a charter — session flags must be added into a list we own.
List<String> argv = mutableArgv(argvWithFleet(cfg, spec));
String agentSessionId = applySessionIdentity(argv, spec.sessionName(), spec.resumeSessionId());
return new Launch(workerEnv, argvWithModel(argv, cfg), agentSessionId);
return new Launch(workerEnv, argvWithAutoCompact(argvWithModel(argv, cfg), cfg), agentSessionId);
}
/**
@@ -463,6 +463,30 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
return withModel;
}
/**
* Pin a bounded auto-compaction window on the command line via {@code --autocompact <tokens>},
* opt-in per profile (CB-634's sibling ticket: a member that runs out of context dies mid-turn
* and its {@code fleet_reply} — the whole point of the turn — is lost with it; opencode already
* forces {@code compaction.auto: true} unconditionally, CB-523, but Claude Code has no equivalent
* and runs at the backend's own default window).
*
* <p>Mirrors {@link #argvWithModel}: appended after it, so it survives the {@code ccs <profile>}
* wrapper the same way {@code --model} does, and outranks env/settings and the operator's own
* {@code argv}. Verified: {@code claude 2.1.241 --help} lists {@code --autocompact <auto|tokens>}
* (either the literal {@code auto}, or an integer 100k–1M) — {@link FleetConfig#load} rejects a
* configured value outside that band before this ever runs, so the flag Claude Code receives here
* is always in range.
*/
private static List<String> argvWithAutoCompact(List<String> argv, FleetConfig.Profile cfg) {
if (cfg.autoCompactWindow() == null) {
return argv;
}
List<String> withAutoCompact = mutableArgv(argv);
withAutoCompact.add("--autocompact");
withAutoCompact.add(String.valueOf(cfg.autoCompactWindow()));
return withAutoCompact;
}
// --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) ---
/** Spawn a worker for the default profile in the resolved default cwd. */
@@ -209,9 +209,19 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
@Override
protected Launch buildLaunch(FleetConfig.Profile cfg, LaunchSpec spec) {
Map<String, String> workerEnv = baseEnv(cfg);
// autoCompactWindow's opencode lever (limit.context) only targets a specific provider/model
// entry, so it needs model: in "provider/model" form. A profile that opts in without that
// shape gets no silent no-op — log it, once, here, whether or not writeConfig ends up running.
boolean wantsContextLimit = cfg.autoCompactWindow() != null && splitProviderModel(cfg.model()) != null;
if (cfg.autoCompactWindow() != null && !wantsContextLimit) {
log.warn("profile '{}' sets autoCompactWindow but model '{}' is not \"<provider>/<model>\" "
+ "form — opencode's per-model context limit could not be applied for this profile",
cfg.profile(), cfg.model());
}
// A config file is needed for the bridge MCP mount, a member charter, the IDE MCP (+ its
// guidance overlay, CB-634), or a pinned endpoint (CB-508).
if (cfg.hasMcp() || cfg.hasIdeMcp() || spec.charter() != null || hasCustomProvider(cfg)) {
// guidance overlay, CB-634), a pinned endpoint (CB-508), or a resolvable autoCompactWindow.
if (cfg.hasMcp() || cfg.hasIdeMcp() || spec.charter() != null || hasCustomProvider(cfg)
|| wantsContextLimit) {
workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg, spec.charter(), spec.cwd()).toString());
}
applyGitToken(workerEnv, cfg);
@@ -360,6 +370,9 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
if (hasCustomProvider(cfg)) {
addCustomProvider(root, cfg);
}
if (cfg.autoCompactWindow() != null) {
applyContextLimit(root, cfg);
}
Path cfgFile = dir.resolve("opencode.json");
// Built with Jackson rather than string concatenation: the provider block is nested and
@@ -404,18 +417,68 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
* default gateway — a worker quietly talking to the wrong endpoint is the failure this avoids.
*/
private static String[] splitModelSelector(FleetConfig.Profile cfg) {
String model = cfg.model();
int slash = model == null ? -1 : model.indexOf('/');
if (model == null || model.isBlank() || slash <= 0 || slash == model.length() - 1) {
String[] parts = splitProviderModel(cfg.model());
if (parts == null) {
throw new IllegalArgumentException(
"profile " + cfg.profile() + " sets baseUrl (a pinned opencode endpoint) so"
+ " model: must be \"<provider>/<model>\", e.g."
+ " \"local-vllm/deepseek-v4-flash\"; got "
+ (model == null ? "null" : '"' + model + '"'));
+ (cfg.model() == null ? "null" : '"' + cfg.model() + '"'));
}
return parts;
}
/**
* Split {@code model} into its {@code provider} and {@code model} halves, or {@code null} when
* it is not in that shape (unset/blank, or no non-trailing {@code /}). Unlike
* {@link #splitModelSelector}, non-throwing — callers that only *optionally* need the split
* (autoCompactWindow's context-limit application) use this to fall back to a WARN rather than an
* exception, since a profile without {@code baseUrl} is not required to name a provider/model.
*/
private static String[] splitProviderModel(String model) {
int slash = model == null ? -1 : model.indexOf('/');
if (model == null || model.isBlank() || slash <= 0 || slash == model.length() - 1) {
return null;
}
return new String[]{model.substring(0, slash), model.substring(slash + 1)};
}
/**
* Apply the per-profile {@code autoCompactWindow} as opencode's per-model context limit.
*
* <p>opencode has no absolute "compact at N tokens" knob — its {@code compaction} block only
* exposes {@code auto}/{@code prune}/{@code reserved}/{@code tail_turns}/
* {@code preserve_recent_tokens} — so the real lever is the model's own
* {@code provider.<p>.models.<m>.limit.context}, which bounds the window opencode compacts
* <em>within</em> rather than compacting exactly AT it the way Claude Code's {@code --autocompact}
* does.
*
* <p>Uses get-or-create nodes ({@code withObject}) at every level so this MERGES with any provider
* block {@link #addCustomProvider} already wrote for a custom-provider (pinned-endpoint) profile —
* it must never overwrite that block's {@code npm}/{@code name}/{@code options}. For a gateway
* profile (no {@code baseUrl}, so no prior provider block) this writes a partial
* {@code provider.<p>.models.<m>.limit} override, which opencode merges over its own built-in
* provider definition.
*
* <p>opencode's {@code limit} schema requires both {@code context} and {@code output}; there is no
* independent signal for the latter here, so 16384 is written as a safe default (documented in
* {@code fleetd.example.yaml}).
*
* <p>Silently does nothing when {@code model:} is not in {@code provider/model} form — a warning
* for that case is already logged once in {@code buildLaunch}, so this stays quiet rather than
* duplicating it.
*/
private static void applyContextLimit(ObjectNode root, FleetConfig.Profile cfg) {
String[] parts = splitProviderModel(cfg.model());
if (parts == null) {
return;
}
ObjectNode limit = root.withObject("provider").withObject(parts[0])
.withObject("models").withObject(parts[1]).withObject("limit");
limit.put("context", cfg.autoCompactWindow());
limit.put("output", 16384);
}
/**
* The OpenAI-compatible base URL for {@code baseUrl}. A bare {@code host:port} gets {@code /v1}
* appended (where these servers put the API); a URL that already carries a path is taken as-is,
@@ -42,6 +42,49 @@ class FleetConfigTest {
assertTrue(cfg.guard().hostSet().contains("ollama.ltms.dev"));
}
@Test
void aProfileWithAnAutoCompactWindowBelowTheAcceptedRangeIsRejectedAtLoad(@TempDir Path dir)
throws Exception {
Path f = dir.resolve("low-window.yaml");
Files.writeString(f, """
profiles:
ltms-local:
baseUrl: http://gx00.gw:8000
autoCompactWindow: 50000
""");
IllegalStateException e = assertThrows(IllegalStateException.class, () -> FleetConfig.load(f));
assertTrue(e.getMessage().contains("ltms-local"), "the offending profile is named");
assertTrue(e.getMessage().contains("autoCompactWindow"));
}
@Test
void aProfileWithAnAutoCompactWindowInRangeLoadsFine(@TempDir Path dir) throws Exception {
Path f = dir.resolve("in-range-window.yaml");
Files.writeString(f, """
profiles:
ltms-local:
baseUrl: http://gx00.gw:8000
autoCompactWindow: 250000
""");
FleetConfig cfg = FleetConfig.load(f);
assertEquals(250_000, cfg.profiles().get("ltms-local").autoCompactWindow());
}
@Test
void aProfileWithNoAutoCompactWindowLeavesItNull(@TempDir Path dir) throws Exception {
Path f = dir.resolve("no-window.yaml");
Files.writeString(f, """
profiles:
ltms-local:
baseUrl: http://gx00.gw:8000
""");
assertNull(FleetConfig.load(f).profiles().get("ltms-local").autoCompactWindow(),
"unset means off — today's behaviour, unchanged");
}
@Test
void appliesDefaultsForMissingSections(@TempDir Path dir) throws Exception {
Path f = dir.resolve("minimal.yaml");
@@ -1138,6 +1138,42 @@ class ClaudeCodeLauncherTest {
assertNull(startEnv(herdr).get("ANTHROPIC_MODEL"));
}
// ── autoCompactWindow: --autocompact is pinned on the command line, opt-in per profile ───────
/** A launcher for a profile identical but for its {@code autoCompactWindow:} — the only variable. */
private ClaudeCodeLauncher serviceWithAutoCompactWindow(FakeHerdr herdr, Integer window) {
FleetConfig.Profile cfg = profileWithAutoCompactWindow(window);
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
_ -> null);
}
private static FleetConfig.Profile profileWithAutoCompactWindow(Integer window) {
return new FleetConfig.Profile("sonnet", "http://gx00.gw:8000", "claude-sonnet-5", null,
"BRIDGED_WORKER_TOKEN", List.of("ccs", "sonnet"), "tab", "bridged-workers",
"w #{n}", "http://127.0.0.1:8765/mcp", null, null, null, null, null, Map.of(),
null, null, null, null, null, null, null, null, window);
}
@Test
void aConfiguredAutoCompactWindowIsPassedAsAnAutocompactFlag() {
FakeHerdr herdr = new FakeHerdr();
serviceWithAutoCompactWindow(herdr, 250_000).spawn("sonnet", null, null);
List<String> args = spawnedArgs(herdr);
int flag = args.indexOf("--autocompact");
assertTrue(flag >= 0, "the flag is what survives a wrapper argv like [ccs, sonnet]");
assertEquals("250000", args.get(flag + 1));
}
@Test
void aProfileWithNoAutoCompactWindowGetsNoAutocompactFlag() {
FakeHerdr herdr = new FakeHerdr();
serviceWithAutoCompactWindow(herdr, null).spawn("sonnet", null, null);
assertFalse(spawnedArgs(herdr).contains("--autocompact"));
}
// --- CB-539: subscription-profile opt-in ----------------------------------------------------
/** A claude-code profile on the subscription: no baseUrl (by design), no off-sub endpoint. */
@@ -495,6 +495,71 @@ class OpenCodeLauncherTest {
"without a baseUrl opencode resolves its own provider as before");
}
// --- autoCompactWindow: opencode has no absolute compact-at-N knob, so this is applied as the
// model's own limit.context, only when model: resolves to "provider/model" -----------------
private static FleetConfig.Profile opencodeCfgWithAutoCompactWindow(String model, String baseUrl,
Integer window) {
return new FleetConfig.Profile("gemini", baseUrl, model, null, "BRIDGED_WORKER_TOKEN",
List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", null,
null, null, null, null, FleetConfig.Profile.KIND_OPENCODE, Map.of(), null, null,
null, null, null, null, null, null, window);
}
@Test
void autoCompactWindowIsAppliedAsThePerModelContextLimit(@TempDir Path root) throws Exception {
FakeHerdr herdr = new FakeHerdr();
service(herdr, root, opencodeCfgWithAutoCompactWindow("openai/gpt-5", null, 250_000)).spawn();
String cfgPath = startEnv(herdr).get("OPENCODE_CONFIG");
assertNotNull(cfgPath, "autoCompactWindow alone must trigger config generation, with no MCP"
+ " and no custom provider set");
JsonNode limit = new ObjectMapper().readTree(Path.of(cfgPath).toFile())
.path("provider").path("openai").path("models").path("gpt-5").path("limit");
assertEquals(250_000, limit.path("context").asInt());
assertEquals(16384, limit.path("output").asInt(),
"opencode's limit schema requires both keys; output gets a safe documented default");
}
@Test
void autoCompactWindowMergesIntoACustomProviderRatherThanOverwritingIt(@TempDir Path root)
throws Exception {
FakeHerdr herdr = new FakeHerdr();
service(herdr, root, opencodeCfgWithAutoCompactWindow(
"local-vllm/deepseek-v4-flash", "http://127.0.0.1:8000", 300_000)).spawn();
JsonNode provider = new ObjectMapper()
.readTree(Path.of(startEnv(herdr).get("OPENCODE_CONFIG")).toFile())
.path("provider").path("local-vllm");
assertEquals("@ai-sdk/openai-compatible", provider.path("npm").asText(),
"addCustomProvider's own fields must survive the later limit merge");
assertEquals(300_000, provider.path("models").path("deepseek-v4-flash")
.path("limit").path("context").asInt());
assertEquals("deepseek-v4-flash", provider.path("models").path("deepseek-v4-flash")
.path("name").asText(),
"the model's pre-existing 'name' field must survive the limit merge too");
}
@Test
void autoCompactWindowWithNoProviderSlashInModelGetsNoLimitAndAWarn(@TempDir Path root)
throws Exception {
FakeHerdr herdr = new FakeHerdr();
// mcpUrl set too, only so a config file gets written at all to inspect; a bare model name
// with no other config-triggering knob would leave OPENCODE_CONFIG unset entirely, which is
// also correct (nothing to write) but not what this test is asserting.
service(herdr, root, new FleetConfig.Profile("gemini", null, "some-free-model", null,
"BRIDGED_WORKER_TOKEN", List.of("opencode"), "tab", "bridged-workers",
"opencode: {model} #{n}", "http://127.0.0.1:8765/mcp", null, null, null, null,
FleetConfig.Profile.KIND_OPENCODE, Map.of(), null, null, null, null, null, null,
null, null, 250_000)).spawn();
String cfgPath = startEnv(herdr).get("OPENCODE_CONFIG");
JsonNode json = new ObjectMapper().readTree(Path.of(cfgPath).toFile());
assertTrue(json.path("provider").isMissingNode(),
"a bare model name cannot be targeted at a specific provider/model limit entry — "
+ "no silent no-op, but also no broken partial write");
}
// --- CB-634: IDE Index MCP + guidance overlay (opencode does not read CLAUDE.local.md) -------
private static FleetConfig.Profile opencodeIdeCfg(String mcpUrl, String ideUrl, String cwd) {