diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java index df70228..3cb9745 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java @@ -396,6 +396,46 @@ class OpenCodeLauncherTest { assertNotNull(ex.getMessage()); } + /** + * fleetd #176 fix 2's per-adapter guard: {@code StatusRefiner.classify} is written for the + * Claude Code TUI only, and the spawn-readiness gate must never run it against an opencode + * pane. {@code agentType("opencode")} deliberately satisfies the OTHER guard (the corroborating + * liveness check) so it cannot be what makes this test pass — only the {@code namePrefix} + * check can be. If that check were ever removed, this pane's {@code ❯} content would refine + * straight to IDLE and the gate would report ready before the backend actually was. + */ + @Test + void opencodePaneIsNeverRefinedEvenWhenItsContentLooksLikeAnIdleClaudePrompt(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + herdr.agentStatus("unknown"); // always UNKNOWN + herdr.agentType("opencode"); // non-null — satisfies the liveness guard on its own + herdr.readText("some-host:~ user$ ❯ "); // content StatusRefiner.classify reads as IDLE + long[] clock = {0}; + OpenCodeLauncher svc = new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + Map.of("gemini", opencodeCfg(null, null, null)), "gemini", _ -> null, + 1000, () -> clock[0], () -> clock[0] += 50, root, root); + + assertThrows(PeerUnreachableException.class, + () -> svc.spawn(new SpawnRequest(null, null, null))); + + assertTrue(clock[0] >= 1000, + "an opencode pane must never refine to injectable, however its content reads — " + + "the gate has to wait out the full timeout: clock only reached " + clock[0]); + // A blanket "agent.read is never called" does not hold here: the timeout path itself reads + // the pane tail (source=recent) for its own log message, on every timeout, regardless of + // adapter — see HerdrPeerLauncher.readPaneQuietly. So assert on the refiner's OWN probe + // source (StatusRefiner.PROBE_SOURCE = "detection") instead — that call happens only inside + // StatusRefiner.refine, so its absence proves the refiner itself was never reached for this + // opencode pane, not merely that its answer was discarded. + long detectionReads = herdr.calls.stream() + .filter(c -> c.method().equals("agent.read")) + .filter(c -> "detection".equals(((Map) c.params()).get("source"))) + .count(); + assertEquals(0, detectionReads, + "the refiner's own pane probe (source=detection) must never run against an " + + "opencode pane — the namePrefix guard has to stop it before that call"); + } + @Test void spawnReturnsHandleWhenGateDisabled(@TempDir Path root) { FakeHerdr herdr = new FakeHerdr();