Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2757bc7185 | |||
| 7655f1b51a | |||
| a5efb7c676 | |||
| 5a8cf4cb4d |
@@ -130,6 +130,28 @@ herdrSocket: ~/.config/herdr/herdr.sock
|
||||
# second inline server named `intellij`, and adds an IDE charter that pins every
|
||||
# ide_* call to the member's own worktree. A URL, not a boolean — host and port
|
||||
# are host-specific. Set it only on a host where the IDE actually runs.
|
||||
# ideProjectDir → repo-relative module dir the IDE opens and the overlay pins (CB-634). Only read
|
||||
# when ideMcpUrl is set. This repo's Maven pom lives in `bridged/`, not at the
|
||||
# worktree root, so opening the root imports no module and ide_* resolves nothing;
|
||||
# set this to `bridged`. Omit for a repo whose project is the worktree root.
|
||||
# ideOpenCommand → host command that opens ideProjectDir in the IDE at spawn (CB-634 auto-open).
|
||||
# Only read when ideMcpUrl is set. `{dir}` is replaced with the absolute module
|
||||
# dir and the command runs through `/bin/sh -c`, so set env inline if needed —
|
||||
# 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
|
||||
@@ -244,6 +266,9 @@ profiles:
|
||||
# cwd: /Users/me/src/myrepo # pin the working dir; omit to inherit the primary's
|
||||
# parityOverlay: [".env", ".envrc"] # the default; never add .mcp.json or .claude/settings.local.json — see above
|
||||
# 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,
|
||||
@@ -290,7 +303,10 @@ public record FleetConfig(
|
||||
Boolean subscription,
|
||||
String exhaustedPattern,
|
||||
String credentialId,
|
||||
String ideMcpUrl) {
|
||||
String ideMcpUrl,
|
||||
String ideProjectDir,
|
||||
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";
|
||||
@@ -353,6 +369,17 @@ public record FleetConfig(
|
||||
// host-specific, mirroring mcpUrl. When set, the member gets the IDE Index MCP mounted
|
||||
// (pinned to its own worktree via the charter). Blank ⇒ off.
|
||||
ideMcpUrl = (ideMcpUrl == null || ideMcpUrl.isBlank()) ? null : ideMcpUrl;
|
||||
// CB-634 auto-open: both are only read when hasIdeMcp(). ideProjectDir is the repo-relative
|
||||
// module dir IntelliJ must open (this repo's pom lives in `bridged/`, not at the root), and
|
||||
// it is also the project_path the overlay pins. Blank ⇒ the worktree root (unchanged before
|
||||
// auto-open). ideOpenCommand is the host command that opens that dir in the IDE, with {dir}
|
||||
// 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.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -397,7 +424,47 @@ 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);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible constructor without the CB-634 auto-open fields
|
||||
* ({@code ideProjectDir}/{@code ideOpenCommand}) — a profile that opts into IDE MCP still
|
||||
* pins the worktree root and does not auto-open. Keeps pre-auto-open call sites (and any YAML
|
||||
* that omits the keys) compiling and behaving identically. This is the shape the canonical
|
||||
* constructor had before the two fields were added.
|
||||
*/
|
||||
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) {
|
||||
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad,
|
||||
subscription, exhaustedPattern, credentialId, ideMcpUrl, null, null);
|
||||
}
|
||||
|
||||
/** True when this profile is served by the Claude Code adapter (the default kind). */
|
||||
@@ -1168,6 +1235,7 @@ public record FleetConfig(
|
||||
warnUnknownTopLevelKeys(yaml, path);
|
||||
rejectDuplicateMemberSlots(yaml);
|
||||
rejectNegativeMaxLoad(yaml);
|
||||
rejectAutoCompactWindowOutOfRange(yaml);
|
||||
rejectUnknownKind(yaml);
|
||||
rejectUnknownAuthMode(yaml);
|
||||
rejectUnknownPlacement(yaml);
|
||||
@@ -1470,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);
|
||||
|
||||
|
||||
@@ -215,9 +215,12 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
// CB-634: the IDE guidance is delivered as an on-disk CLAUDE.local.md overlay, NOT through
|
||||
// the charter — the charter returns to role -> reply only. Best-effort: a failed overlay
|
||||
// must never fail the spawn, and `writeIdeOverlay` no-ops unless the cwd is a provisioned
|
||||
// worktree (see its .git-file safety gate).
|
||||
// worktree (see its .git-file safety gate). The overlay pins, and the auto-open opens, the
|
||||
// module dir (this repo's pom is in `bridged/`, not at the worktree root) — see ideProjectPath.
|
||||
if (cfg.hasIdeMcp()) {
|
||||
writeIdeOverlay(spec.cwd());
|
||||
String projectPath = PeerLauncher.ideProjectPath(spec.cwd(), cfg.ideProjectDir());
|
||||
writeIdeOverlay(spec.cwd(), projectPath);
|
||||
PeerLauncher.openInIde(projectPath, cfg.ideOpenCommand(), log);
|
||||
}
|
||||
|
||||
Map<String, String> workerEnv = baseEnv(cfg);
|
||||
@@ -245,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,7 +359,8 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
/**
|
||||
* Deliver the shared IDE guidance ({@link PeerLauncher#ideOverlayText}) as an on-disk
|
||||
* {@code CLAUDE.local.md} overlay beside the project's own {@code CLAUDE.md} (CB-634), and
|
||||
* register the overlay in the worktree's {@code info/exclude} so it never shows as untracked.
|
||||
* register the overlay in the repository's common {@code info/exclude} so it never shows as
|
||||
* untracked (git reads a worktree's excludes from the common dir, not the per-worktree gitdir).
|
||||
*
|
||||
* <p><strong>Safety gate:</strong> the overlay is written ONLY when {@code cwd/.git} is a
|
||||
* <em>regular file</em> — a provisioned worktree keeps a {@code .git} FILE holding a
|
||||
@@ -367,8 +371,12 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
*
|
||||
* <p>Best-effort: a failure is logged at debug and swallowed — a failed overlay must never fail
|
||||
* the spawn.
|
||||
*
|
||||
* @param cwd the member's worktree root, where the {@code CLAUDE.local.md} file is written
|
||||
* @param projectPath the module dir the overlay pins {@code project_path} to (see
|
||||
* {@link PeerLauncher#ideProjectPath}); equals {@code cwd} when no module subdir
|
||||
*/
|
||||
private static void writeIdeOverlay(String cwd) {
|
||||
private static void writeIdeOverlay(String cwd, String projectPath) {
|
||||
try {
|
||||
Path dotGit = Path.of(cwd, ".git");
|
||||
if (!Files.isRegularFile(dotGit)) {
|
||||
@@ -376,13 +384,24 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
// cwd is not a repo at all). Never write into it.
|
||||
return;
|
||||
}
|
||||
Files.writeString(Path.of(cwd, "CLAUDE.local.md"), PeerLauncher.ideOverlayText(cwd));
|
||||
// The overlay FILE lives at the worktree root (claude-code's cwd), but its CONTENT pins
|
||||
// project_path to the module dir the IDE opened (projectPath), not the worktree root.
|
||||
Files.writeString(Path.of(cwd, "CLAUDE.local.md"), PeerLauncher.ideOverlayText(projectPath));
|
||||
String gitdirLine = Files.readString(dotGit).trim();
|
||||
Path gitDir = Path.of(gitdirLine.replaceFirst("^gitdir:\\s*", ""));
|
||||
if (!gitDir.isAbsolute()) {
|
||||
gitDir = Path.of(cwd).resolve(gitDir).normalize();
|
||||
}
|
||||
Path exclude = gitDir.resolve("info").resolve("exclude");
|
||||
// git reads info/exclude from the COMMON dir, never the per-worktree gitdir (only
|
||||
// info/sparse-checkout is per-worktree). A provisioned worktree's gitdir is
|
||||
// <common>/worktrees/<name>, so the common dir is two levels up; writing the entry into
|
||||
// the per-worktree gitdir leaves it un-honoured and the overlay shows as untracked.
|
||||
Path commonDir = gitDir;
|
||||
if (gitDir.getParent() != null && gitDir.getParent().getFileName() != null
|
||||
&& "worktrees".equals(gitDir.getParent().getFileName().toString())) {
|
||||
commonDir = gitDir.getParent().getParent();
|
||||
}
|
||||
Path exclude = commonDir.resolve("info").resolve("exclude");
|
||||
Files.createDirectories(exclude.getParent());
|
||||
String overlayLine = "CLAUDE.local.md";
|
||||
if (!Files.exists(exclude) || Files.readAllLines(exclude).stream().noneMatch(overlayLine::equals)) {
|
||||
@@ -444,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. */
|
||||
|
||||
@@ -24,6 +24,9 @@ import java.util.function.Function;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link HerdrPeerLauncher} adapter for <strong>opencode</strong> — an open-source,
|
||||
* provider-agnostic terminal coding agent. Its whole reason for existing is to prove the
|
||||
@@ -50,6 +53,8 @@ import java.util.function.Supplier;
|
||||
*/
|
||||
public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OpenCodeLauncher.class);
|
||||
|
||||
/** Label prefix for this adapter's herdr agent names (drives naming + orphan reap). */
|
||||
private static final String NAME_PREFIX = "opencode";
|
||||
|
||||
@@ -204,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);
|
||||
@@ -341,16 +356,23 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
ide.put("url", cfg.ideMcpUrl());
|
||||
ide.put("enabled", true);
|
||||
|
||||
// CB-634: pin the overlay and open the IDE at the module dir (this repo's pom is
|
||||
// in `bridged/`, not at the worktree root) — see PeerLauncher.ideProjectPath.
|
||||
String projectPath = PeerLauncher.ideProjectPath(cwd, cfg.ideProjectDir());
|
||||
Path rules = dir.resolve("ide-rules.md");
|
||||
Files.writeString(rules, PeerLauncher.ideOverlayText(cwd));
|
||||
Files.writeString(rules, PeerLauncher.ideOverlayText(projectPath));
|
||||
rules.toFile().deleteOnExit();
|
||||
// The array may already hold the member-charter path; withArray gets-or-creates.
|
||||
root.withArray("instructions").add(rules.toAbsolutePath().toString());
|
||||
PeerLauncher.openInIde(projectPath, cfg.ideOpenCommand(), log);
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -395,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,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package dev.ltms.fleet.peer;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* SPI for materializing a connected peer — the only way the bridge core creates or tears down
|
||||
* a peer process. Every launcher is a first-party, in-tree adapter selected by (future) profile
|
||||
@@ -36,20 +39,73 @@ public interface PeerLauncher {
|
||||
* clobbered. It pins every {@code ide_*} call to the member's own worktree, which is the whole
|
||||
* point of the mechanism. Both launchers render their own overlay from this single source.
|
||||
*
|
||||
* @param worktree the member's isolated worktree path, embedded as the {@code project_path} pin
|
||||
* @param projectPath the path the member must pin every {@code ide_*} call to — the module dir
|
||||
* IntelliJ opened as the project, which is {@link #ideProjectPath} of the
|
||||
* member's own worktree (the worktree root when no module subdir is set)
|
||||
*/
|
||||
static String ideOverlayText(String worktree) {
|
||||
static String ideOverlayText(String projectPath) {
|
||||
return "## IDE code intelligence — your worktree only\n"
|
||||
+ "An IntelliJ IDE Index MCP server is mounted as `mcp__intellij__ide_*`. Prefer it "
|
||||
+ "over `grep`/`find` for symbol lookups, references, call and type hierarchy, and "
|
||||
+ "diagnostics — it resolves the real AST, text search does not.\n\n"
|
||||
+ "Every `ide_*` call MUST pass `project_path: \"" + worktree + "\"` — your own "
|
||||
+ "Every `ide_*` call MUST pass `project_path: \"" + projectPath + "\"` — your own "
|
||||
+ "worktree — and never any other path. A call without it errors "
|
||||
+ "`multiple_projects_open`; a call with a different path reads another checkout, "
|
||||
+ "not your changes. This is not the primary's IDE: it is your worktree, pinned to "
|
||||
+ "you.";
|
||||
}
|
||||
|
||||
/**
|
||||
* The absolute path IntelliJ must open as the project, and the {@code project_path} the overlay
|
||||
* pins (CB-634). It is {@code cwd} resolved against {@code ideProjectDir}. The distinction
|
||||
* matters because this repo (like {@code fleet/fleetd}) keeps its Maven module in a subdir
|
||||
* ({@code bridged/}), not at the worktree root: opening the root imports no module and
|
||||
* {@code ide_*} resolves nothing, so the module dir is the correct pin and open target.
|
||||
*
|
||||
* @param cwd the member's worktree root
|
||||
* @param ideProjectDir repo-relative module subdir, or {@code null}/blank for the worktree root
|
||||
* @return the absolute, normalized module dir as a string
|
||||
*/
|
||||
static String ideProjectPath(String cwd, String ideProjectDir) {
|
||||
Path base = Path.of(cwd);
|
||||
if (ideProjectDir == null || ideProjectDir.isBlank()) {
|
||||
return base.toString();
|
||||
}
|
||||
return base.resolve(ideProjectDir).normalize().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: open {@code projectPath} in the host IDE by running {@code openCommand} with
|
||||
* every {@code {dir}} replaced by {@code projectPath} (CB-634 auto-open). The command runs
|
||||
* through {@code /bin/sh -c} so an operator can set env inline — e.g.
|
||||
* {@code "env DISPLAY=:10.0 idea {dir}"} — because the daemon's own env may lack {@code DISPLAY}.
|
||||
*
|
||||
* <p>A blank command is a no-op: the profile opted into IDE MCP but not auto-open, so the
|
||||
* operator opens the module by hand. The child process is detached and its exit is not awaited;
|
||||
* any failure is logged and swallowed, because a member must spawn whether or not an IDE is
|
||||
* running. There is no close half yet (CB-634 defers it): an opened module stays open until the
|
||||
* operator closes it, and opening the same module again just refocuses it.
|
||||
*
|
||||
* @param projectPath the module dir to open (typically {@link #ideProjectPath})
|
||||
* @param openCommand the host command template, with {@code {dir}} substituted; null/blank ⇒ no-op
|
||||
* @param log the calling launcher's logger, for the best-effort WARN
|
||||
*/
|
||||
static void openInIde(String projectPath, String openCommand, Logger log) {
|
||||
if (openCommand == null || openCommand.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String cmd = openCommand.replace("{dir}", projectPath);
|
||||
try {
|
||||
new ProcessBuilder("/bin/sh", "-c", cmd)
|
||||
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||
.redirectError(ProcessBuilder.Redirect.DISCARD)
|
||||
.start();
|
||||
log.info("CB-634 auto-open: launched IDE open for {}", projectPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("CB-634 auto-open of '{}' failed (member still spawns): {}", projectPath, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of {@link Capability capabilities} this launcher declares. A peer whose profile
|
||||
* opts into a git-forge token should include {@link Capability#SELF_PR}; the base set for
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -12,6 +12,7 @@ import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||
import dev.ltms.fleet.peer.Capability;
|
||||
import dev.ltms.fleet.peer.MemberRole;
|
||||
import dev.ltms.fleet.peer.PeerHandle;
|
||||
import dev.ltms.fleet.peer.PeerLauncher;
|
||||
import dev.ltms.fleet.peer.PeerUnreachableException;
|
||||
import dev.ltms.fleet.peer.SpawnRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -74,6 +75,15 @@ class ClaudeCodeLauncherTest {
|
||||
null, null, null, null, null, null, null, null, null, ideMcpUrl);
|
||||
}
|
||||
|
||||
/** As {@link #ideProfile} but carrying the CB-634 auto-open fields (module subdir + open command). */
|
||||
private FleetConfig.Profile ideProfileModule(String ideMcpUrl, String cwd, String ideProjectDir,
|
||||
String ideOpenCommand) {
|
||||
return new FleetConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, cwd, null,
|
||||
null, null, null, null, null, null, null, null, null, ideMcpUrl, ideProjectDir, ideOpenCommand);
|
||||
}
|
||||
|
||||
private ClaudeCodeLauncher launcher(FakeHerdr herdr, FleetConfig.Profile cfg) {
|
||||
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
|
||||
@@ -138,14 +148,17 @@ class ClaudeCodeLauncherTest {
|
||||
}
|
||||
|
||||
// CB-634: the IDE guidance is delivered as a CLAUDE.local.md overlay (written only into a
|
||||
// provisioned worktree — cwd with a `.git` FILE) and registered in the worktree's info/exclude.
|
||||
// provisioned worktree — cwd with a `.git` FILE) and registered in the repository's COMMON
|
||||
// info/exclude. git reads a worktree's excludes from the common dir, not the per-worktree
|
||||
// gitdir (only info/sparse-checkout is per-worktree), so a real provisioned layout
|
||||
// <common>/worktrees/<name> must land the entry in <common>/info/exclude.
|
||||
|
||||
@Test
|
||||
void writeIdeOverlayWritesClaudeLocalAndAddsItToInfoExclude(@TempDir Path root) throws Exception {
|
||||
void writeIdeOverlayWritesClaudeLocalAndAddsItToCommonInfoExclude(@TempDir Path root) throws Exception {
|
||||
Path worktree = Files.createDirectory(root.resolve("worktree"));
|
||||
Path gitDir = Files.createDirectory(root.resolve("gitdir"));
|
||||
Files.createDirectories(gitDir.resolve("info"));
|
||||
// A provisioned worktree keeps its gitdir as a `.git` FILE holding a gitdir: <path> pointer.
|
||||
// Real worktree layout: the .git FILE points at <common>/worktrees/<name>.
|
||||
Path commonDir = Files.createDirectory(root.resolve("dotgit"));
|
||||
Path gitDir = Files.createDirectories(commonDir.resolve("worktrees").resolve("wt1"));
|
||||
Files.writeString(worktree.resolve(".git"), "gitdir: " + gitDir);
|
||||
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
@@ -156,10 +169,53 @@ class ClaudeCodeLauncherTest {
|
||||
assertTrue(Files.exists(overlay), "the overlay is written beside the project's CLAUDE.md");
|
||||
assertTrue(Files.readString(overlay).contains("project_path: \"" + worktree + "\""),
|
||||
"the overlay pins every ide_* call to the member's own worktree");
|
||||
Path exclude = gitDir.resolve("info").resolve("exclude");
|
||||
assertTrue(Files.exists(exclude), "info/exclude is created from the gitdir pointer");
|
||||
assertTrue(Files.readAllLines(exclude).contains("CLAUDE.local.md"),
|
||||
"the overlay is registered so it never shows as untracked");
|
||||
Path commonExclude = commonDir.resolve("info").resolve("exclude");
|
||||
assertTrue(Files.exists(commonExclude), "info/exclude is created in the COMMON git dir");
|
||||
assertTrue(Files.readAllLines(commonExclude).contains("CLAUDE.local.md"),
|
||||
"the overlay is registered in the common exclude git actually honours for a worktree");
|
||||
assertFalse(Files.exists(gitDir.resolve("info").resolve("exclude")),
|
||||
"the entry must NOT go to the per-worktree gitdir, which git ignores for excludes");
|
||||
}
|
||||
|
||||
// CB-634 auto-open pin fix: for a repo whose Maven module is a subdir (this repo's pom lives in
|
||||
// `bridged/`, not at the worktree root), the overlay must pin project_path to the MODULE dir the
|
||||
// IDE opened, not the worktree root — else ide_* resolves against a root that imports no module.
|
||||
@Test
|
||||
void writeIdeOverlayPinsModuleDirWhenIdeProjectDirSet(@TempDir Path root) throws Exception {
|
||||
Path worktree = Files.createDirectory(root.resolve("worktree"));
|
||||
Path commonDir = Files.createDirectory(root.resolve("dotgit"));
|
||||
Path gitDir = Files.createDirectories(commonDir.resolve("worktrees").resolve("wt1"));
|
||||
Files.writeString(worktree.resolve(".git"), "gitdir: " + gitDir);
|
||||
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
// ideProjectDir "bridged" ⇒ the pin is <worktree>/bridged, not <worktree>.
|
||||
launcher(herdr, ideProfileModule("http://127.0.0.1:29170/index-mcp/streamable-http",
|
||||
worktree.toString(), "bridged", null)).spawn();
|
||||
|
||||
Path overlay = worktree.resolve("CLAUDE.local.md");
|
||||
assertTrue(Files.exists(overlay), "the overlay file still lives at the worktree root");
|
||||
String module = worktree.resolve("bridged").toString();
|
||||
assertTrue(Files.readString(overlay).contains("project_path: \"" + module + "\""),
|
||||
"the overlay pins the MODULE dir the IDE opened, not the worktree root");
|
||||
assertFalse(Files.readString(overlay).contains("project_path: \"" + worktree + "\""),
|
||||
"the bare worktree root must NOT be the pin when a module subdir is set");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ideProjectPathResolvesModuleSubdirAndDefaultsToWorktreeRoot() {
|
||||
assertEquals("/wt/x/bridged", PeerLauncher.ideProjectPath("/wt/x", "bridged"),
|
||||
"a module subdir resolves under the worktree root");
|
||||
assertEquals("/wt/x", PeerLauncher.ideProjectPath("/wt/x", null),
|
||||
"no module subdir ⇒ the worktree root itself");
|
||||
assertEquals("/wt/x", PeerLauncher.ideProjectPath("/wt/x", " "),
|
||||
"a blank module subdir ⇒ the worktree root itself");
|
||||
}
|
||||
|
||||
@Test
|
||||
void openInIdeIsNoOpAndNeverThrowsWhenCommandBlank() {
|
||||
// A profile that opts into IDE MCP but sets no open command must not fail the spawn.
|
||||
assertDoesNotThrow(() -> PeerLauncher.openInIde("/wt/x", null, LoggerFactory.getLogger("test")));
|
||||
assertDoesNotThrow(() -> PeerLauncher.openInIde("/wt/x", " ", LoggerFactory.getLogger("test")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1082,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) {
|
||||
|
||||
Reference in New Issue
Block a user