e501d39988
A BACKEND_EXHAUSTED classification (stage A) now puts that profile's credential into a BackendQuarantine for a configurable cooldown. A spawn onto a quarantined profile is refused naming the credential and roughly when it lifts; weighted/round-robin/fixed placement skip a quarantined candidate; the quarantine lifts itself on the injected clock; and it is visible on bridge_profiles. Keyed by credential, not by profile name, via the new Profile.credentialId (profiles sharing one credential quarantine together — e.g. two models on one account) and effectiveCredentialId() (unset ⇒ quarantines alone, today's behaviour unchanged). Fixed a related gap along the way: a reload changing exhaustedPattern was silently reported "applied" even though it's deferred — sameLaunchSettings() now catches it too.
502 lines
26 KiB
Java
502 lines
26 KiB
Java
package dev.ltms.bridged.inject;
|
||
|
||
import ch.qos.logback.classic.Level;
|
||
import ch.qos.logback.classic.LoggerContext;
|
||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||
import ch.qos.logback.core.read.ListAppender;
|
||
import dev.ltms.bridged.herdr.AgentControl;
|
||
import dev.ltms.bridged.herdr.FakeHerdr;
|
||
import dev.ltms.bridged.msg.Rendezvous;
|
||
import dev.ltms.bridged.msg.TestTurnTokens;
|
||
import dev.ltms.bridged.msg.TurnToken;
|
||
import org.junit.jupiter.api.Test;
|
||
import org.slf4j.LoggerFactory;
|
||
|
||
import java.util.Set;
|
||
import java.util.regex.Pattern;
|
||
|
||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||
|
||
/** Unit behaviour of the CB-106 completion resolver in isolation from the injector. */
|
||
class CompletionResolverTest {
|
||
|
||
@Test
|
||
void skipsTheScrapeWhenNoSendIsWaiting() {
|
||
FakeHerdr herdr = new FakeHerdr();
|
||
Rendezvous rendezvous = new Rendezvous(); // no waiter opened
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
resolver.resolve("term_a", null); // no in-flight turn captured for this target
|
||
|
||
assertFalse(herdr.called("agent.read"),
|
||
"a turn nobody is blocked on must not cost a transcript scrape");
|
||
}
|
||
|
||
@Test
|
||
void failSkipsTheScrapeWhenNoSendIsWaiting() {
|
||
FakeHerdr herdr = new FakeHerdr();
|
||
Rendezvous rendezvous = new Rendezvous(); // no waiter opened
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
resolver.fail("term_a", null); // no in-flight turn, and no registered waiter to fall back to
|
||
|
||
assertFalse(herdr.called("agent.read"),
|
||
"a wedge nobody is blocked on must not cost a transcript scrape");
|
||
}
|
||
|
||
@Test
|
||
void captureBaselineSkipsTheReadWhenNoSendIsWaiting() {
|
||
FakeHerdr herdr = new FakeHerdr().readText("⏺ X\n❯ ");
|
||
Rendezvous rendezvous = new Rendezvous(); // no waiter opened
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
resolver.captureBaseline("term_a", TestTurnTokens.inert("term_a")); // no send to attribute a later completion to
|
||
|
||
assertFalse(herdr.called("agent.read"),
|
||
"with no waiting send there is no turn to baseline — skip the scrape");
|
||
}
|
||
|
||
// --- CB-115 clean scrape: extract the last assistant block ----------------
|
||
|
||
@Test
|
||
void extractsTheLastAssistantBlockStrippingChrome() {
|
||
String raw = """
|
||
⏺ Reading the file…
|
||
|
||
⏺ Done. The bug was an off-by-one in the loop bound.
|
||
|
||
╭──────────────────────────────────────╮
|
||
│ > │
|
||
╰──────────────────────────────────────╯
|
||
⏵⏵ auto mode on · ? for shortcuts
|
||
""";
|
||
assertEquals("Done. The bug was an off-by-one in the loop bound.",
|
||
CompletionResolver.lastAssistantBlock(raw));
|
||
}
|
||
|
||
@Test
|
||
void keepsMultiLineAssistantContent() {
|
||
String raw = "⏺ Line one.\nLine two.\n❯ ";
|
||
assertEquals("Line one.\nLine two.", CompletionResolver.lastAssistantBlock(raw));
|
||
}
|
||
|
||
@Test
|
||
void fallsBackToRawTextWhenThereIsNoMarker() {
|
||
String raw = "plain worker output with no glyph";
|
||
assertEquals("plain worker output with no glyph", CompletionResolver.lastAssistantBlock(raw));
|
||
}
|
||
|
||
@Test
|
||
void blankScrapeYieldsEmpty() {
|
||
assertTrue(CompletionResolver.lastAssistantBlock("").isEmpty());
|
||
assertTrue(CompletionResolver.lastAssistantBlock(null).isEmpty());
|
||
}
|
||
|
||
@Test
|
||
void stripsSpinnerAndRuleChrome() {
|
||
String raw = """
|
||
⏺ Channel check confirmed — your message got through.
|
||
|
||
✻ Brewed for 11s
|
||
|
||
─────────────────────────────────────
|
||
""";
|
||
assertEquals("Channel check confirmed — your message got through.",
|
||
CompletionResolver.lastAssistantBlock(raw));
|
||
}
|
||
|
||
@Test
|
||
void cutsANextTurnPromptEchoAndTrailingTipsFromTheBlock() {
|
||
// The exact turn-2 leak: the scrape captured the settled answer, then a "✻ Cooked" spinner,
|
||
// then the NEXT turn's echoed prompt, then a "✶ Forming…" spinner and trailing tips/warnings
|
||
// whose lines (⎿, ⚠) are not themselves chrome-terminated. Stopping at the first boundary
|
||
// (the ✻ spinner) is what keeps every one of those interface lines out of the reply.
|
||
String raw = """
|
||
⏺ Channel confirmed — the bridge reply delivered successfully.
|
||
|
||
✻ Cooked for 9s
|
||
|
||
❯ Thanks. Now a small task: what is 17 * 23? Show just the number.
|
||
|
||
|
||
|
||
✶ Forming…
|
||
⎿ Tip: Name your conversations with /rename
|
||
⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY is set
|
||
""";
|
||
assertEquals("Channel confirmed — the bridge reply delivered successfully.",
|
||
CompletionResolver.lastAssistantBlock(raw));
|
||
}
|
||
|
||
// --- CB-115 misattribution guard: suppress a stale (unchanged) completion -------
|
||
|
||
@Test
|
||
void suppressesACompletionWhoseScrapeIsUnchangedFromDelivery() {
|
||
// Rapid back-to-back turn: the pane still shows the PREVIOUS turn's answer when this turn's
|
||
// (misattributed) completion boundary fires. The scrape == the delivery baseline, so the
|
||
// send must NOT be resolved with the stale answer.
|
||
FakeHerdr herdr = new FakeHerdr().readText("⏺ 391\n❯ ");
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a"); // a send is blocked on this turn
|
||
// The turn as captured at delivery: its waiter, and the previous turn's answer still on screen.
|
||
var turn = new CompletionResolver.InFlight(waiter, "391");
|
||
resolver.resolve("term_a", turn); // scrape still "391" == baseline → suppress
|
||
|
||
assertFalse(waiter.isDone(), "a completion with no output change must not resolve the send");
|
||
assertTrue(rendezvous.isWaiting("term_a"), "the send stays waiting for a real reply");
|
||
}
|
||
|
||
@Test
|
||
void resolvesACompletionWhoseScrapeChangedSinceDelivery() {
|
||
FakeHerdr herdr = new FakeHerdr().readText("⏺ No, 391 = 17 × 23.\n❯ "); // the worker's real answer
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a");
|
||
// Delivery baseline was the previous turn's "391"; the scrape now differs → resolve.
|
||
var turn = new CompletionResolver.InFlight(waiter, "391");
|
||
resolver.resolve("term_a", turn);
|
||
|
||
assertTrue(waiter.isDone(), "a completion with new output must resolve the send");
|
||
assertEquals(Rendezvous.Kind.COMPLETION, waiter.getNow(null).kind());
|
||
assertEquals("No, 391 = 17 × 23.", waiter.getNow(null).text());
|
||
}
|
||
|
||
@Test
|
||
void marksAClippedCompletionPaneTail() {
|
||
String block = "⏺ " + "x".repeat(CompletionResolver.MAX_SCRAPE_CHARS + 1) + "\n❯ ";
|
||
FakeHerdr herdr = new FakeHerdr().readText(block);
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a");
|
||
resolver.resolve("term_a", new CompletionResolver.InFlight(waiter, null));
|
||
|
||
assertEquals("x".repeat(CompletionResolver.MAX_SCRAPE_CHARS)
|
||
+ "\n[Pane tail clipped: member did not call bridge_reply.]",
|
||
waiter.getNow(null).text());
|
||
}
|
||
|
||
@Test
|
||
void leavesAnUnclippedCompletionPaneTailUnmarked() {
|
||
FakeHerdr herdr = new FakeHerdr().readText("⏺ complete report\n❯ ");
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a");
|
||
resolver.resolve("term_a", new CompletionResolver.InFlight(waiter, null));
|
||
|
||
assertEquals("complete report", waiter.getNow(null).text());
|
||
}
|
||
|
||
@Test
|
||
void resolvesSynchronouslyBeforePostTurnContextClearing() {
|
||
FakeHerdr herdr = new FakeHerdr().readText("⏺ previous answer\n❯ ");
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
var waiter = rendezvous.open("term_a");
|
||
resolver.captureBaseline("term_a", new TurnToken("term_a", waiter));
|
||
herdr.readText("⏺ answer that /clear would erase\n❯ ");
|
||
|
||
resolver.resolveBeforePostAction("term_a");
|
||
|
||
assertTrue(waiter.isDone(), "the answer is captured before the adapter sends /clear");
|
||
assertEquals("answer that /clear would erase", waiter.getNow(null).text());
|
||
}
|
||
|
||
@Test
|
||
void suppressesAnUnchangedCompletionEvenWhenTheBlockExceedsTheScrapeCap() {
|
||
// The fan-out issue-hunt finding: captureBaseline once stored the RAW (unclipped) assistant
|
||
// block while resolve compares against a clip()'d tail. For a block longer than MAX_SCRAPE_CHARS
|
||
// the two capped representations differ even when the pane never changed, so the CB-115
|
||
// byte-identical guard failed to fire and a stale completion could resolve the send. Both sides
|
||
// must clip identically. The returned-text marker is added only after this comparison, so an
|
||
// unchanged >cap block on rapid back-to-back turns still stays suppressed.
|
||
String longBlock = "⏺ " + "x".repeat(CompletionResolver.MAX_SCRAPE_CHARS + 500) + "\n❯ ";
|
||
FakeHerdr herdr = new FakeHerdr().readText(longBlock);
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a"); // a send is blocked on this turn
|
||
resolver.captureBaseline("term_a", new TurnToken("term_a", waiter)); // baseline is the clipped >cap block
|
||
var turn = resolver.inFlight("term_a");
|
||
assertEquals(CompletionResolver.MAX_SCRAPE_CHARS, turn.baseline().length(),
|
||
"the delivery baseline is clipped to the same cap resolve() applies to the tail");
|
||
|
||
resolver.resolve("term_a", turn); // scrape unchanged → clipped tail == baseline → suppress
|
||
|
||
assertFalse(waiter.isDone(),
|
||
"an unchanged >cap block must still be recognised as stale and suppressed");
|
||
assertTrue(rendezvous.isWaiting("term_a"), "the send stays waiting for a real reply");
|
||
}
|
||
|
||
@Test
|
||
void resolvesWhenThereIsNoBaseline() {
|
||
// No delivery baseline (e.g. the pre-turn read failed) ⇒ never suppress; the completion resolves.
|
||
FakeHerdr herdr = new FakeHerdr().readText("⏺ hello\n❯ ");
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a");
|
||
resolver.resolve("term_a", new CompletionResolver.InFlight(waiter, null));
|
||
|
||
assertTrue(waiter.isDone(), "with no baseline a completion resolves as before");
|
||
assertEquals("hello", waiter.getNow(null).text());
|
||
}
|
||
|
||
@Test
|
||
void resolvesWhenTheScrapeItselfFailsEvenWithABaselinePresent() {
|
||
// The most important branch of the CB-115 guard: a failed read means the resolver could not
|
||
// SEE the screen — "couldn't see", not "no change". It must still resolve the send (an empty
|
||
// tail beats hanging until the caller's timeout), even though a baseline was captured. The
|
||
// baseline here is "" (an empty pane at delivery), so without the !scrapeFailed clause the
|
||
// byte-identical guard would wrongly match the empty tail and suppress.
|
||
FakeHerdr herdr = new FakeHerdr().healthy(false); // agent.read throws HerdrException
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a");
|
||
var turn = new CompletionResolver.InFlight(waiter, ""); // empty pane baselined at delivery
|
||
resolver.resolve("term_a", turn);
|
||
|
||
assertTrue(waiter.isDone(),
|
||
"a failed scrape must still resolve the send, not hang until the caller's timeout");
|
||
assertEquals(Rendezvous.Kind.COMPLETION, waiter.getNow(null).kind());
|
||
assertEquals("", waiter.getNow(null).text(), "the tail is empty because the screen was unreadable");
|
||
}
|
||
|
||
// --- CB-115/CB-116 fail guard: an already-done or absent waiter is left alone ---------
|
||
|
||
@Test
|
||
void failLeavesAnAlreadyResolvedWaiterUntouchedAndSkipsTheScrape() {
|
||
// The send was already resolved (e.g. by the worker's explicit reply) before fail fired.
|
||
// fail must not overwrite that value, and must not even scrape the worker — nobody needs it.
|
||
FakeHerdr herdr = new FakeHerdr().readText("an error screen");
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a");
|
||
var turn = new CompletionResolver.InFlight(waiter, null);
|
||
assertTrue(rendezvous.resolveCompletion(waiter, "already replied"));
|
||
|
||
resolver.fail("term_a", turn);
|
||
|
||
assertFalse(herdr.called("agent.read"),
|
||
"fail must not scrape a waiter that is already done");
|
||
assertEquals(Rendezvous.Kind.COMPLETION, waiter.getNow(null).kind(),
|
||
"fail must not overwrite the existing resolution");
|
||
assertEquals("already replied", waiter.getNow(null).text());
|
||
}
|
||
|
||
@Test
|
||
void failFallsBackToTheRegisteredWaiterWhenThereIsNoInFlightTurn() {
|
||
// A never-delivered readiness failure has no in-flight record but still has a blocked send;
|
||
// fail falls back to the waiter currently registered on the Rendezvous and fails it.
|
||
FakeHerdr herdr = new FakeHerdr().readText("stuck on an error screen");
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a"); // send registered, but no captureBaseline ever ran
|
||
resolver.fail("term_a", null); // no in-flight turn → fall back to the registered waiter
|
||
|
||
assertTrue(waiter.isDone(), "fail falls back to the registered waiter when no turn is in flight");
|
||
assertEquals(Rendezvous.Kind.FAILED, waiter.getNow(null).kind());
|
||
assertEquals("stuck on an error screen", waiter.getNow(null).text());
|
||
}
|
||
|
||
@Test
|
||
void failIsLoggedAtWarnWithTheReason() {
|
||
// CB-564: this used to be a bare DEBUG "failed send to X via turn-stall fallback" — a symptom
|
||
// with no cause, and below the level anyone watching for member health would see. A fail that
|
||
// resolves a caller's blocked send is at least WARN and must carry the reason.
|
||
LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory();
|
||
ch.qos.logback.classic.Logger resolverLog =
|
||
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger(CompletionResolver.class);
|
||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||
appender.setContext(ctx);
|
||
appender.start();
|
||
resolverLog.addAppender(appender);
|
||
resolverLog.setLevel(Level.WARN);
|
||
try {
|
||
FakeHerdr herdr = new FakeHerdr().readText("stuck on an error screen");
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
var waiter = rendezvous.open("term_a");
|
||
|
||
resolver.fail("term_a", null);
|
||
|
||
String warn = appender.list.stream()
|
||
.filter(e -> e.getLevel().equals(Level.WARN))
|
||
.map(ILoggingEvent::getFormattedMessage)
|
||
.findFirst()
|
||
.orElse("no turn-stall WARN logged");
|
||
assertTrue(warn.contains("term_a"), "the log names the target: " + warn);
|
||
assertTrue(warn.contains("stuck on an error screen"), "the log carries the reason: " + warn);
|
||
assertTrue(waiter.isDone());
|
||
} finally {
|
||
resolverLog.detachAppender(appender);
|
||
}
|
||
}
|
||
|
||
// --- CB-116 waiter identity: a late completion never crosses into the next turn ---------
|
||
|
||
@Test
|
||
void aLateCompletionForOneTurnNeverResolvesTheNextTurnsWaiter() {
|
||
// The cross-turn stale reply the conversation test surfaced: turn N's completion fallback
|
||
// fires AFTER turn N was resolved by an explicit bridge_reply and turn N+1 has opened its own
|
||
// waiter on the same session. Resolving "whatever is waiting now" would hand turn N's stale
|
||
// scrape to turn N+1; targeting turn N's captured waiter makes the late completion a no-op.
|
||
FakeHerdr herdr = new FakeHerdr().readText("⏺ turn N answer\n❯ ");
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
var waiterN = rendezvous.open("term_a"); // turn N's send
|
||
// The turn as the injector captured it at delivery (waiter + pre-turn baseline).
|
||
var turnN = new CompletionResolver.InFlight(waiterN, "an earlier answer");
|
||
|
||
// Turn N is resolved by the worker's explicit reply, and its send deregisters the waiter.
|
||
assertTrue(rendezvous.resolve("term_a", "N replied"));
|
||
rendezvous.close("term_a", waiterN); // the sender's finally, before the next turn opens
|
||
|
||
// Turn N+1's send opens its own waiter on the same session (CB-548: open fails if the
|
||
// previous waiter is still registered, so a clean turn deregisters it first as above).
|
||
var waiterN1 = rendezvous.open("term_a");
|
||
|
||
resolver.resolve("term_a", turnN); // turn N's completion fallback finally fires
|
||
|
||
assertFalse(waiterN1.isDone(), "turn N's late completion must not resolve turn N+1's waiter");
|
||
assertEquals(Rendezvous.Kind.REPLY, waiterN.getNow(null).kind(),
|
||
"turn N stays resolved by its own reply");
|
||
assertTrue(rendezvous.isWaiting("term_a"), "turn N+1 is still awaiting its own resolution");
|
||
}
|
||
|
||
// --- CB-578 stage A: backend-exhausted classification ---------------------------------
|
||
|
||
@Test
|
||
void classifiesAMatchingScrapeAsBackendExhaustedInsteadOfACompletedReply() {
|
||
String block = "⏺ Working on it...\nThe usage limit has been reached. Try again later.\n❯ ";
|
||
FakeHerdr herdr = new FakeHerdr().readText(block);
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
ExhaustedPatternLookup patterns = target -> Pattern.compile("usage limit has been reached");
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, patterns, ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a");
|
||
resolver.resolve("term_a", new CompletionResolver.InFlight(waiter, null));
|
||
|
||
assertTrue(waiter.isDone(), "a matching scrape still resolves the blocked send");
|
||
assertEquals(Rendezvous.Kind.BACKEND_EXHAUSTED, waiter.getNow(null).kind(),
|
||
"not reported as a completed reply — the classification is distinct");
|
||
}
|
||
|
||
@Test
|
||
void theExhaustedReasonCarriesTheMatchedLine() {
|
||
String block = "⏺ Working on it...\nThe usage limit has been reached. Try again later.\n❯ ";
|
||
FakeHerdr herdr = new FakeHerdr().readText(block);
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
ExhaustedPatternLookup patterns = target -> Pattern.compile("usage limit has been reached");
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, patterns, ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a");
|
||
resolver.resolve("term_a", new CompletionResolver.InFlight(waiter, null));
|
||
|
||
assertEquals("backend exhausted (usage limit): The usage limit has been reached. Try again later.",
|
||
waiter.getNow(null).text(), "the reason names the real cause and carries the matched line");
|
||
}
|
||
|
||
@Test
|
||
void aWinningBackendExhaustedClassificationNotifiesTheExhaustionSink() {
|
||
String block = "⏺ Working on it...\nThe usage limit has been reached. Try again later.\n❯ ";
|
||
FakeHerdr herdr = new FakeHerdr().readText(block);
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
ExhaustedPatternLookup patterns = target -> Pattern.compile("usage limit has been reached");
|
||
java.util.List<String> notified = new java.util.ArrayList<>();
|
||
ExhaustionSink sink = (target, reason) -> notified.add(target + ": " + reason);
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, patterns, sink);
|
||
|
||
var waiter = rendezvous.open("term_a");
|
||
resolver.resolve("term_a", new CompletionResolver.InFlight(waiter, null));
|
||
|
||
assertEquals(1, notified.size(), "the sink is notified exactly once for the winning classification");
|
||
assertTrue(notified.get(0).startsWith("term_a: "), "the sink is told which target exhausted");
|
||
assertTrue(notified.get(0).contains("The usage limit has been reached"),
|
||
"the sink is told the matched reason: " + notified.get(0));
|
||
}
|
||
|
||
@Test
|
||
void aLosingBackendExhaustedClassificationNeverNotifiesTheExhaustionSink() {
|
||
// The waiter was already resolved (e.g. by the worker's own reply) before this scrape landed —
|
||
// resolveExhausted loses the race and must return false, so the sink must not fire either.
|
||
String block = "⏺ Working on it...\nThe usage limit has been reached. Try again later.\n❯ ";
|
||
FakeHerdr herdr = new FakeHerdr().readText(block);
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
ExhaustedPatternLookup patterns = target -> Pattern.compile("usage limit has been reached");
|
||
java.util.List<String> notified = new java.util.ArrayList<>();
|
||
ExhaustionSink sink = (target, reason) -> notified.add(target + ": " + reason);
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, patterns, sink);
|
||
|
||
var waiter = rendezvous.open("term_a");
|
||
var turn = new CompletionResolver.InFlight(waiter, null);
|
||
assertTrue(rendezvous.resolveCompletion(waiter, "already replied"));
|
||
|
||
resolver.resolve("term_a", turn);
|
||
|
||
assertTrue(notified.isEmpty(), "a classification that loses the race must not quarantine anything");
|
||
assertEquals("already replied", waiter.getNow(null).text(), "the earlier resolution stands untouched");
|
||
}
|
||
|
||
@Test
|
||
void aNonMatchingScrapeResolvesAsAnOrdinaryCompletion() {
|
||
FakeHerdr herdr = new FakeHerdr().readText("⏺ complete report\n❯ ");
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
ExhaustedPatternLookup patterns = target -> Pattern.compile("usage limit has been reached");
|
||
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, patterns, ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a");
|
||
resolver.resolve("term_a", new CompletionResolver.InFlight(waiter, null));
|
||
|
||
assertEquals(Rendezvous.Kind.COMPLETION, waiter.getNow(null).kind(),
|
||
"a scrape that does not match the pattern is an ordinary completion");
|
||
assertEquals("complete report", waiter.getNow(null).text());
|
||
}
|
||
|
||
@Test
|
||
void aProfileWithNoConfiguredPatternKeepsTodaysCompletionFallbackUnchanged() {
|
||
// Even a scrape that WOULD have matched some other profile's pattern must resolve as a
|
||
// plain completion when this target's own profile has none configured (CB-578 criterion 4).
|
||
String block = "⏺ The usage limit has been reached.\n❯ ";
|
||
FakeHerdr herdr = new FakeHerdr().readText(block);
|
||
Rendezvous rendezvous = new Rendezvous();
|
||
CompletionResolver resolver =
|
||
new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
|
||
|
||
var waiter = rendezvous.open("term_a");
|
||
resolver.resolve("term_a", new CompletionResolver.InFlight(waiter, null));
|
||
|
||
assertEquals(Rendezvous.Kind.COMPLETION, waiter.getNow(null).kind(),
|
||
"no pattern configured for this target's profile ⇒ unchanged completion-fallback behaviour");
|
||
assertEquals("The usage limit has been reached.", waiter.getNow(null).text());
|
||
}
|
||
|
||
@Test
|
||
void coverageIsOffWhenNoProfileHasAPatternConfigured() {
|
||
assertEquals("off (no profile has an exhaustedPattern configured; profiles: [terra])",
|
||
CompletionResolver.coverage(Set.of("terra"), Set.of()));
|
||
}
|
||
|
||
@Test
|
||
void coverageIsFullWhenEveryProfileHasAPatternConfigured() {
|
||
assertEquals("full (all profiles configured: [gx10, terra])",
|
||
CompletionResolver.coverage(Set.of("terra", "gx10"), Set.of("terra", "gx10")));
|
||
}
|
||
|
||
@Test
|
||
void coverageIsPartialAndNamesWhichProfilesAreConfigured() {
|
||
assertEquals("partial (configured: [terra]; not configured: [gx10])",
|
||
CompletionResolver.coverage(Set.of("terra", "gx10"), Set.of("terra")));
|
||
}
|
||
}
|