diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index 712894d..337704a 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -1075,7 +1075,33 @@ public final class Fleetd { */ static FleetMcp.LeadConfigDirSource leadConfigDirSource(Supplier> profiles, Map leaders) { - return new FleetMcp.LeadConfigDirSource(leadConfigDirLookup(profiles, leaders)); + return new FleetMcp.LeadConfigDirSource(leadConfigDirLookup(profiles, leaders), + leadContextWindowLookup(profiles, leaders)); + } + + /** + * Per-lead-name factory for the effective auto-compact window {@link LeadContextGauge} scales + * its HIGH threshold against — the same {@code fleet.leaders..profile} link {@link + * #leadConfigDirLookup} already follows, one step further to that profile's own {@link + * FleetConfig.Profile#effectiveAutoCompactWindow()}. A lead entry that names no + * {@code profile:}, or whose named profile is not configured, or whose profile resolves no + * window at all, returns {@code null} — {@link LeadContextGauge} then falls back to its own + * fixed HIGH threshold. + */ + static Function leadContextWindowLookup(Supplier> profiles, + Map leaders) { + return leadName -> { + FleetConfig.Leader lead = leaders.get(leadName); + if (lead == null || lead.profile() == null || lead.profile().isBlank()) { + return null; + } + FleetConfig.Profile leadProfile = profiles.get().get(lead.profile()); + if (leadProfile == null) { + return null; + } + Integer window = leadProfile.effectiveAutoCompactWindow(); + return window == null ? null : window.longValue(); + }; } /** @@ -1100,19 +1126,32 @@ public final class Fleetd { */ static Function leadContextLookup(LeadContextGauge gauge, AgentControl agents, Supplier> liveLeadTerminals, Function configDirForLeadName) { + return leadContextLookup(gauge, agents, liveLeadTerminals, configDirForLeadName, _ -> null); + } + + /** + * As above, additionally resolving each lead's effective auto-compact window (normally {@link + * #leadContextWindowLookup}'s return) and passing it through to {@link LeadContextGauge#read}, + * so the heartbeat's own HIGH reading scales with that lead's real window instead of always the + * gauge's fixed fallback. + */ + static Function leadContextLookup(LeadContextGauge gauge, AgentControl agents, + Supplier> liveLeadTerminals, Function configDirForLeadName, + Function windowForLeadName) { return terminal -> { String leadName = liveLeadTerminals.get().get(terminal); if (leadName == null) { return LeadContextGauge.Reading.unknown(); } String configDir = configDirForLeadName.apply(leadName); + Long effectiveWindow = windowForLeadName.apply(leadName); Agent live; try { live = agents.get(terminal); } catch (RuntimeException e) { return LeadContextGauge.Reading.unknown(); } - return gauge.read(configDir, live.sessionId(), live.agentType()); + return gauge.read(configDir, live.sessionId(), live.agentType(), effectiveWindow); }; } @@ -1129,6 +1168,14 @@ public final class Fleetd { leadContextLookup(gauge, agents, liveLeadTerminals, configDirForLeadName)); } + /** As above, additionally threading the effective-window lookup through. */ + static LeadHeartbeatLoop.LeadContextSource leadContextSource(LeadContextGauge gauge, AgentControl agents, + Supplier> liveLeadTerminals, Function configDirForLeadName, + Function windowForLeadName) { + return new LeadHeartbeatLoop.LeadContextSource( + leadContextLookup(gauge, agents, liveLeadTerminals, configDirForLeadName, windowForLeadName)); + } + /** * fleetd #248 / fleetd#201 Unit 5: package-private factory for the per-target backend-error * pattern lookup {@link CompletionResolver} classifies a pane scrape against. Closes over the diff --git a/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java b/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java index 52df9ca..c802a8a 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java +++ b/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java @@ -405,7 +405,8 @@ final class FleetdAssembly { TimeUnit.SECONDS.toNanos(hb.idleAfterSeconds()), hb.backoffMs(), hb.quietNudgeCap(), metrics, Fleetd.leadContextSource(leadContextGauge, router.leadAgents(), leads, - Fleetd.leadConfigDirLookup(() -> config.get().profiles(), leaders)), + Fleetd.leadConfigDirLookup(() -> config.get().profiles(), leaders), + Fleetd.leadContextWindowLookup(() -> config.get().profiles(), leaders)), Boolean.TRUE.equals(hb.contextHighNudge()), requireOperatorConfirm); heartbeat.start(); } else { diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java index 62da9a8..114dd60 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java @@ -798,6 +798,25 @@ public record FleetConfig( return isSubscription() ? SUBSCRIPTION_CREDENTIAL_ID : profile; } + /** + * The auto-compaction window a launched Claude Code session actually runs on: {@code env: + * CLAUDE_CODE_AUTO_COMPACT_WINDOW} when it parses as an integer, since that environment + * variable wins over the {@code --autocompact} flag {@link #autoCompactWindow} produces (see + * {@code ClaudeCodeArguments}); {@link #autoCompactWindow} otherwise. {@code null} when + * neither resolves to a usable number. + */ + public Integer effectiveAutoCompactWindow() { + String envValue = env.get(CLAUDE_CODE_AUTO_COMPACT_WINDOW_ENV); + if (envValue == null) { + return autoCompactWindow; + } + try { + return Integer.valueOf(envValue.trim()); + } catch (NumberFormatException e) { + return autoCompactWindow; + } + } + /** True when this profile's workers are granted a forge token to open their own PR (CB-302). */ public boolean hasGitToken() { return gitTokenEnv != null && !gitTokenEnv.isBlank(); @@ -2184,6 +2203,12 @@ public record FleetConfig( 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; + /** + * The {@code env:} key a launched Claude Code session reads for its auto-compaction window, + * ahead of the {@code --autocompact} launch flag {@code autoCompactWindow} produces (see + * {@link Profile#effectiveAutoCompactWindow()}). + */ + static final String CLAUDE_CODE_AUTO_COMPACT_WINDOW_ENV = "CLAUDE_CODE_AUTO_COMPACT_WINDOW"; /** * Reject a profile whose {@code autoCompactWindow:} is set but outside the token band Claude @@ -2265,13 +2290,13 @@ public record FleetConfig( if (!(entry.getValue() instanceof Map profile) || !(profile.get("autoCompactWindow") instanceof Number window) || !(profile.get("env") instanceof Map env) - || !env.containsKey("CLAUDE_CODE_AUTO_COMPACT_WINDOW")) { + || !env.containsKey(CLAUDE_CODE_AUTO_COMPACT_WINDOW_ENV)) { continue; } Object kind = profile.get("kind"); boolean claudeCode = kind == null || String.valueOf(kind).isBlank() || Profile.KIND_CLAUDE_CODE.equalsIgnoreCase(String.valueOf(kind)); - Object envValue = env.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW"); + Object envValue = env.get(CLAUDE_CODE_AUTO_COMPACT_WINDOW_ENV); if (claudeCode && !String.valueOf(window).equals(String.valueOf(envValue))) { String name = String.valueOf(entry.getKey()); names.add(name); diff --git a/fleetd/src/main/java/dev/ltms/fleet/lead/LeadContextGauge.java b/fleetd/src/main/java/dev/ltms/fleet/lead/LeadContextGauge.java index b24d48e..13c925a 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/lead/LeadContextGauge.java +++ b/fleetd/src/main/java/dev/ltms/fleet/lead/LeadContextGauge.java @@ -97,14 +97,19 @@ public final class LeadContextGauge { static final long DEFAULT_CACHE_TTL_MILLIS = 5_000; /** - * Live tokens at or above this count report {@link State#HIGH}. On the host this was measured - * on, auto-compaction actually fires around 267,000–270,000 tokens, but the point of a HIGH - * state is to warn before that happens, not at it — 200,000 is the standard Claude context - * window size and a sensible built-in default: no config key is required to pick it, and a - * lead crossing it is already deep enough into its window that a compaction is foreseeable. + * Fallback HIGH threshold used when a caller resolves no effective auto-compact window for the + * lead being read (see {@link #read(String, String, String, Long)}) — the built-in default so + * no config key is required to get a warning at all. */ static final long HIGH_THRESHOLD_TOKENS = 200_000; + /** + * The fraction of a resolved effective auto-compact window that HIGH warns at, so the warning + * margin scales with the window instead of only ever meaning something against the fixed + * {@link #HIGH_THRESHOLD_TOKENS} fallback. + */ + static final double HIGH_THRESHOLD_FRACTION = 2.0 / 3.0; + /** The only peer kind this reader understands ({@code Agent.agentType()}'s wire value). */ private static final String CLAUDE_AGENT_TYPE = "claude"; @@ -167,6 +172,17 @@ public final class LeadContextGauge { * transcript format */ public Reading read(String configDir, String sessionId, String agentType) { + return read(configDir, sessionId, agentType, null); + } + + /** + * @param effectiveWindowTokens the caller's resolved effective auto-compact window for this + * lead's own profile, or {@code null} when it cannot be resolved. + * HIGH fires at {@link #HIGH_THRESHOLD_FRACTION} of this value; + * {@code null} (or a non-positive value) falls back to the fixed + * {@link #HIGH_THRESHOLD_TOKENS} + */ + public Reading read(String configDir, String sessionId, String agentType, Long effectiveWindowTokens) { if (sessionId == null || sessionId.isBlank()) { return Reading.unknown(); } @@ -182,12 +198,20 @@ public final class LeadContextGauge { if (cached != null && now - cached.readAtMillis() < ttlMillis) { return cached.reading(); } - Reading fresh = readUncached(base, sessionId); + Reading fresh = readUncached(base, sessionId, highThreshold(effectiveWindowTokens)); cache.put(cacheKey, new CacheEntry(fresh, now)); return fresh; } - private Reading readUncached(String base, String sessionId) { + /** {@link #HIGH_THRESHOLD_FRACTION} of {@code effectiveWindowTokens}, or the fixed fallback. */ + private static long highThreshold(Long effectiveWindowTokens) { + if (effectiveWindowTokens == null || effectiveWindowTokens <= 0) { + return HIGH_THRESHOLD_TOKENS; + } + return (long) (effectiveWindowTokens * HIGH_THRESHOLD_FRACTION); + } + + private Reading readUncached(String base, String sessionId, long highThreshold) { diskReads.incrementAndGet(); Path file = findTranscript(base, sessionId); if (file == null) { @@ -199,7 +223,7 @@ public final class LeadContextGauge { } catch (IOException e) { return Reading.unknown(); } - return parse(tail); + return parse(tail, highThreshold); } /** @@ -258,7 +282,7 @@ public final class LeadContextGauge { * read raced — see the "torn final line" section of the class javadoc) is skipped, not fatal. * Only when none of the remaining lines parse does this report {@link State#UNKNOWN}. */ - private Reading parse(TailRead tail) { + private Reading parse(TailRead tail, long highThreshold) { String text = new String(tail.bytes(), StandardCharsets.UTF_8); List lines = new ArrayList<>(List.of(text.split("\n", -1))); if (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) { @@ -299,7 +323,7 @@ public final class LeadContextGauge { if (tokens == null) { return new Reading(State.UNKNOWN, null, compactions); } - State state = tokens >= HIGH_THRESHOLD_TOKENS ? State.HIGH : State.OK; + State state = tokens >= highThreshold ? State.HIGH : State.OK; return new Reading(state, tokens, compactions); } diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java index 82e3367..d12fa00 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -284,10 +284,23 @@ public final class FleetMcp { * no profile, or that profile sets no {@code configDir} override — either * way {@link LeadContextGauge} then falls back to its own built-in default, * exactly as before this ticket + * @param windowFor lead name → that lead's profile's effective auto-compact window (see + * {@code dev.ltms.fleet.config.FleetConfig.Profile + * #effectiveAutoCompactWindow()}), or {@code null} when it cannot be + * resolved — either way {@link LeadContextGauge} falls back to its own + * fixed HIGH threshold */ - public record LeadConfigDirSource(Function configDirFor) { - /** Inert source — every lead reads {@link LeadContextGauge}'s built-in default {@code configDir}. */ - public static LeadConfigDirSource none() { return new LeadConfigDirSource(_ -> null); } + public record LeadConfigDirSource(Function configDirFor, Function windowFor) { + /** Inert source — every lead reads {@link LeadContextGauge}'s built-in defaults. */ + public static LeadConfigDirSource none() { return new LeadConfigDirSource(_ -> null, _ -> null); } + + /** + * Constructor that resolves no window — every lead this source answers for keeps + * {@link LeadContextGauge}'s fixed HIGH threshold. + */ + public LeadConfigDirSource(Function configDirFor) { + this(configDirFor, _ -> null); + } } /** @@ -2153,7 +2166,8 @@ public final class FleetMcp { m.put("self", true); } String configDir = leadConfigDirs.configDirFor().apply(name); - m.put("context", contextView(contextGauge, live, configDir)); + Long effectiveWindow = leadConfigDirs.windowFor().apply(name); + m.put("context", contextView(contextGauge, live, configDir, effectiveWindow)); return m; } @@ -2163,12 +2177,15 @@ public final class FleetMcp { * {@code fleet.leaders..profile} → that profile's own {@code configDir:} — or {@code null} * when the lead's entry names no profile, or that profile sets no override, in which case * {@link LeadContextGauge#read} falls back to its own built-in default - * ({@code /.claude}). + * ({@code /.claude}). {@code effectiveWindowTokens} is the same lead's resolved + * auto-compact window, or {@code null} when it cannot be resolved, in which case the gauge + * falls back to its own fixed HIGH threshold instead. */ - private static Map contextView(LeadContextGauge contextGauge, Agent live, String configDir) { + private static Map contextView(LeadContextGauge contextGauge, Agent live, String configDir, + Long effectiveWindowTokens) { String sessionId = live == null ? null : live.sessionId(); String agentType = live == null ? null : live.agentType(); - LeadContextGauge.Reading reading = contextGauge.read(configDir, sessionId, agentType); + LeadContextGauge.Reading reading = contextGauge.read(configDir, sessionId, agentType, effectiveWindowTokens); Map c = new LinkedHashMap<>(); c.put("state", reading.state().name().toLowerCase()); if (reading.tokens() != null) { diff --git a/fleetd/src/main/java/dev/ltms/fleet/msg/LeadHeartbeatLoop.java b/fleetd/src/main/java/dev/ltms/fleet/msg/LeadHeartbeatLoop.java index 37c6caa..2a5e211 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/msg/LeadHeartbeatLoop.java +++ b/fleetd/src/main/java/dev/ltms/fleet/msg/LeadHeartbeatLoop.java @@ -461,9 +461,9 @@ public final class LeadHeartbeatLoop { .append(reading.compactions()).append(' ').append(compactionWord).append(" so far."); } else { // A HIGH reading always carries a non-null token count today: LeadContextGauge only - // reaches HIGH by comparing a number against HIGH_THRESHOLD_TOKENS. That invariant - // lives in another class and nothing asserts it, so this branch does not rely on it — - // it drops the token clause rather than printing "null tokens". + // reaches HIGH by comparing a number against a threshold. That invariant lives in + // another class and nothing asserts it, so this branch does not rely on it — it drops + // the token clause rather than printing "null tokens". sb.append(" (").append(reading.compactions()).append(' ').append(compactionWord) .append(" so far)."); } diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadContextWindowLookupTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadContextWindowLookupTest.java new file mode 100644 index 0000000..2301f4c --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadContextWindowLookupTest.java @@ -0,0 +1,96 @@ +package dev.ltms.fleet; + +import dev.ltms.fleet.config.FleetConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * {@link Fleetd#leadContextWindowLookup} is the factory wired into {@code + * FleetMcp.LeadConfigDirSource} and {@code LeadHeartbeatLoop.LeadContextSource} so {@link + * dev.ltms.fleet.lead.LeadContextGauge} scales its HIGH threshold against a lead's own profile's + * effective auto-compact window instead of always the gauge's fixed fallback — the same {@code + * fleet.leaders..profile} link {@link Fleetd#leadConfigDirLookup} already follows, one step + * further to {@link FleetConfig.Profile#effectiveAutoCompactWindow()}. + */ +class FleetdLeadContextWindowLookupTest { + + private static FleetConfig.Profile profileWithWindow(String name, Integer autoCompactWindow, + Map env) { + return new FleetConfig.Profile(name, null, "claude-sonnet-5", null, null, null, + "tab", "fleet", "w #{n}", null, null, null, null, null, null, env, + null, null, true, null, null, null, null, null, autoCompactWindow, null); + } + + private static FleetConfig.Leader leadOnProfile(String profile) { + return new FleetConfig.Leader(profile, "lead: primary", 1, "lead:", 10, "claude", "claude-sonnet-5"); + } + + @Test + @DisplayName("a lead on a profile that sets autoCompactWindow resolves to that window") + void leadOnAProfileWithAutoCompactWindowResolvesToIt() { + Map profiles = + Map.of("opus", profileWithWindow("opus", 250_000, Map.of())); + Map leaders = Map.of("primary", leadOnProfile("opus")); + Function lookup = Fleetd.leadContextWindowLookup(() -> profiles, leaders); + + assertEquals(250_000L, lookup.apply("primary")); + } + + @Test + @DisplayName("yaml and env disagree: the lookup resolves the env value, not the yaml one") + void yamlAndEnvDisagreeLookupResolvesTheEnvValue() { + Map profiles = Map.of("opus", + profileWithWindow("opus", 250_000, Map.of("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "150000"))); + Map leaders = Map.of("primary", leadOnProfile("opus")); + Function lookup = Fleetd.leadContextWindowLookup(() -> profiles, leaders); + + assertEquals(150_000L, lookup.apply("primary")); + } + + @Test + @DisplayName("a lead entry with no `profile:` resolves to null, not a thrown exception") + void recogniseOnlyLeadWithNoProfileResolvesToNull() { + Map profiles = + Map.of("opus", profileWithWindow("opus", 250_000, Map.of())); + FleetConfig.Leader recogniseOnly = new FleetConfig.Leader(null, "lead: primary", 1, "lead:", 10, + "claude", "claude-sonnet-5"); + Map leaders = Map.of("primary", recogniseOnly); + Function lookup = Fleetd.leadContextWindowLookup(() -> profiles, leaders); + + assertNull(lookup.apply("primary")); + } + + @Test + @DisplayName("a lead naming a profile that is not configured resolves to null, not a thrown exception") + void leadOnAnUnconfiguredProfileResolvesToNull() { + Map leaders = Map.of("primary", leadOnProfile("ghost-profile")); + Function lookup = Fleetd.leadContextWindowLookup(Map::of, leaders); + + assertNull(lookup.apply("primary")); + } + + @Test + @DisplayName("a lead on a profile that resolves no window at all resolves to null") + void leadOnAProfileWithNoWindowResolvesToNull() { + Map profiles = + Map.of("opus", profileWithWindow("opus", null, Map.of())); + Map leaders = Map.of("primary", leadOnProfile("opus")); + Function lookup = Fleetd.leadContextWindowLookup(() -> profiles, leaders); + + assertNull(lookup.apply("primary")); + } + + @Test + @DisplayName("an unrecognised lead name resolves to null, not a thrown exception") + void unrecognisedLeadNameResolvesToNull() { + Function lookup = Fleetd.leadContextWindowLookup(Map::of, Map.of()); + + assertNull(lookup.apply("ghost-lead")); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigProfileEffectiveAutoCompactWindowTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigProfileEffectiveAutoCompactWindowTest.java new file mode 100644 index 0000000..be2d82c --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigProfileEffectiveAutoCompactWindowTest.java @@ -0,0 +1,65 @@ +package dev.ltms.fleet.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * {@link FleetConfig.Profile#effectiveAutoCompactWindow()} resolves the window a launched Claude + * Code session actually runs on, not just the {@code autoCompactWindow:} launch flag — {@code env: + * CLAUDE_CODE_AUTO_COMPACT_WINDOW} overrides that flag, so a profile setting both resolves from the + * environment variable. + */ +class FleetConfigProfileEffectiveAutoCompactWindowTest { + + private static FleetConfig.Profile profile(Integer autoCompactWindow, Map env) { + return new FleetConfig.Profile("sonnet", null, "claude-sonnet-5", null, null, null, + "tab", "fleet", "w #{n}", null, null, null, null, null, null, env, + null, null, true, null, null, null, null, null, autoCompactWindow, null); + } + + @Test + @DisplayName("yaml and env disagree: the env var wins, not the yaml key") + void yamlAndEnvDisagreeEnvWins() { + FleetConfig.Profile p = profile(250_000, Map.of("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "150000")); + + assertEquals(150_000, p.effectiveAutoCompactWindow(), + "the two inputs must give DIFFERENT thresholds (150,000 vs 250,000) and the env value must win"); + } + + @Test + @DisplayName("only autoCompactWindow set: that value resolves") + void onlyAutoCompactWindowSetResolvesToIt() { + FleetConfig.Profile p = profile(250_000, Map.of()); + + assertEquals(250_000, p.effectiveAutoCompactWindow()); + } + + @Test + @DisplayName("only the env var set: that value resolves") + void onlyEnvVarSetResolvesToIt() { + FleetConfig.Profile p = profile(null, Map.of("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "150000")); + + assertEquals(150_000, p.effectiveAutoCompactWindow()); + } + + @Test + @DisplayName("neither set: resolves to null") + void neitherSetResolvesToNull() { + FleetConfig.Profile p = profile(null, Map.of()); + + assertNull(p.effectiveAutoCompactWindow()); + } + + @Test + @DisplayName("an unparseable env value falls back to autoCompactWindow rather than throwing") + void unparseableEnvValueFallsBackToAutoCompactWindow() { + FleetConfig.Profile p = profile(250_000, Map.of("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "not-a-number")); + + assertEquals(250_000, p.effectiveAutoCompactWindow()); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/lead/LeadContextGaugeHighThresholdTest.java b/fleetd/src/test/java/dev/ltms/fleet/lead/LeadContextGaugeHighThresholdTest.java new file mode 100644 index 0000000..33dd520 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/lead/LeadContextGaugeHighThresholdTest.java @@ -0,0 +1,91 @@ +package dev.ltms.fleet.lead; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@code LeadContextGauge}'s HIGH threshold scales with the caller's resolved effective + * auto-compact window, so the margin it warns at makes sense against a window that can legally sit + * as low as {@code 100_000}, not only against the fixed fallback. These properties pin that + * scaling, its boundary, and the fallback used when no window is resolvable. + */ +class LeadContextGaugeHighThresholdTest { + + private static final String SESSION_ID = "55555555-5555-5555-5555-555555555555"; + + private static String usageLine(long tokens) { + return "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"usage\":{" + + "\"input_tokens\":" + tokens + ",\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}}"; + } + + private static String writeTranscript(Path configDir, String sessionId, long tokens) throws IOException { + Path projectDir = configDir.resolve("projects").resolve("some-project-slug"); + Files.createDirectories(projectDir); + Files.writeString(projectDir.resolve(sessionId + ".jsonl"), usageLine(tokens) + "\n", StandardCharsets.UTF_8); + return configDir.toString(); + } + + @Test + @DisplayName("an effective window of 100000 reports HIGH strictly below 100000") + void effectiveWindowOf100000ReportsHighStrictlyBelow100000(@TempDir Path tmp) throws IOException { + // 90,000 is below the 100,000 window itself, but above a fixed 200,000 fallback would ever + // reach — only a threshold that scales with the window can report HIGH here. + String configDir = writeTranscript(tmp, SESSION_ID, 90_000); + LeadContextGauge gauge = new LeadContextGauge(); + + LeadContextGauge.Reading reading = gauge.read(configDir, SESSION_ID, "claude", 100_000L); + + assertEquals(LeadContextGauge.State.HIGH, reading.state(), + "90,000 tokens against a 100,000 effective window must already be HIGH, with margin to spare " + + "before a compaction at the window itself"); + } + + @Test + @DisplayName("the boundary sits strictly between OK and HIGH on both sides") + void boundarySitsStrictlyBetweenOkAndHighOnBothSides(@TempDir Path tmp) throws IOException { + long window = 100_000L; + long threshold = (long) (window * (2.0 / 3.0)); // 66,666 + + String belowConfigDir = writeTranscript(tmp.resolve("below"), SESSION_ID, threshold - 1); + LeadContextGauge belowGauge = new LeadContextGauge(); + assertEquals(LeadContextGauge.State.OK, + belowGauge.read(belowConfigDir, SESSION_ID, "claude", window).state(), + "one token short of the threshold must stay OK"); + + String atConfigDir = writeTranscript(tmp.resolve("at"), SESSION_ID, threshold); + LeadContextGauge atGauge = new LeadContextGauge(); + assertEquals(LeadContextGauge.State.HIGH, + atGauge.read(atConfigDir, SESSION_ID, "claude", window).state(), + "exactly at the threshold must already be HIGH"); + } + + @Test + @DisplayName("with no effective window resolvable, the threshold is still the fixed 200000 default") + void noEffectiveWindowFallsBackToTheFixed200000Default(@TempDir Path tmp) throws IOException { + String belowConfigDir = writeTranscript(tmp.resolve("below"), SESSION_ID, 199_999); + LeadContextGauge belowGauge = new LeadContextGauge(); + assertEquals(LeadContextGauge.State.OK, + belowGauge.read(belowConfigDir, SESSION_ID, "claude", null).state(), + "one token short of the fixed default must stay OK when no window is resolvable"); + + String atConfigDir = writeTranscript(tmp.resolve("at"), SESSION_ID, 200_000); + LeadContextGauge atGauge = new LeadContextGauge(); + assertEquals(LeadContextGauge.State.HIGH, + atGauge.read(atConfigDir, SESSION_ID, "claude", null).state(), + "the fixed default must still be 200,000 when no window is resolvable"); + + String legacyConfigDir = writeTranscript(tmp.resolve("legacy"), SESSION_ID, 200_000); + LeadContextGauge legacyGauge = new LeadContextGauge(); + assertEquals(LeadContextGauge.State.HIGH, + legacyGauge.read(legacyConfigDir, SESSION_ID, "claude").state(), + "the 3-arg read() (no window argument at all) must behave exactly like passing a null window"); + } +}