diff --git a/bridged/fleetd.example.yaml b/bridged/fleetd.example.yaml index ba58eb0..8c47d03 100644 --- a/bridged/fleetd.example.yaml +++ b/bridged/fleetd.example.yaml @@ -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 ` 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 diff --git a/bridged/src/main/java/dev/ltms/fleet/config/FleetConfig.java b/bridged/src/main/java/dev/ltms/fleet/config/FleetConfig.java index c616d66..5a34371 100644 --- a/bridged/src/main/java/dev/ltms/fleet/config/FleetConfig.java +++ b/bridged/src/main/java/dev/ltms/fleet/config/FleetConfig.java @@ -294,6 +294,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 + * } 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 + * within, and only when {@code model} resolves to a + * {@code provider/model} pair. */ @JsonIgnoreProperties(ignoreUnknown = true) public record Profile(String profile, String baseUrl, String model, @@ -311,7 +324,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"; @@ -381,6 +395,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. } /** @@ -425,7 +443,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 argv, + String placement, String workspace, String tabLabel, String mcpUrl, + String cwd, List parityOverlay, String gitTokenEnv, String gitHostEnv, + String kind, Map 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); } /** @@ -1290,6 +1330,7 @@ public record FleetConfig( warnUnknownTopLevelKeys(yaml, path); rejectDuplicateMemberSlots(yaml); rejectNegativeMaxLoad(yaml); + rejectAutoCompactWindowOutOfRange(yaml); rejectUnknownKind(yaml); rejectUnknownAuthMode(yaml); rejectUnknownPlacement(yaml); @@ -1592,6 +1633,52 @@ public record FleetConfig( } } + /** Lowest {@code autoCompactWindow} Claude Code's {@code --autocompact } flag accepts. */ + static final int AUTO_COMPACT_WINDOW_MIN = 100_000; + /** Highest {@code autoCompactWindow} Claude Code's {@code --autocompact } 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 } flag accepts (100k–1M), naming both the profile and + * the value. + * + *

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 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 KNOWN_KINDS = Set.of(Profile.KIND_CLAUDE_CODE, Profile.KIND_OPENCODE); diff --git a/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java b/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java index cda7c64..1a58907 100644 --- a/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java @@ -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 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 }, + * 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). + * + *

Mirrors {@link #argvWithModel}: appended after it, so it survives the {@code ccs } + * 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 } + * (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 argvWithAutoCompact(List argv, FleetConfig.Profile cfg) { + if (cfg.autoCompactWindow() == null) { + return argv; + } + List 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. */ diff --git a/bridged/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java index 5d3f6b6..673284a 100644 --- a/bridged/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java @@ -209,9 +209,19 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { @Override protected Launch buildLaunch(FleetConfig.Profile cfg, LaunchSpec spec) { Map 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 \"/\" " + + "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 \"/\", 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. + * + *

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.

.models..limit.context}, which bounds the window opencode compacts + * within rather than compacting exactly AT it the way Claude Code's {@code --autocompact} + * does. + * + *

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.

.models..limit} override, which opencode merges over its own built-in + * provider definition. + * + *

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}). + * + *

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, diff --git a/bridged/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java b/bridged/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java index 53052cc..d98990d 100644 --- a/bridged/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java +++ b/bridged/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java @@ -43,6 +43,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"); diff --git a/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java index e5a7628..175181b 100644 --- a/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java @@ -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 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. */ diff --git a/bridged/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java index 5611161..e0a2c5d 100644 --- a/bridged/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java @@ -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) {