diff --git a/fleetd/src/main/java/dev/ltms/fleet/herdr/PaneLocator.java b/fleetd/src/main/java/dev/ltms/fleet/herdr/PaneLocator.java index 39b085fa..c3b6f9d1 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/herdr/PaneLocator.java +++ b/fleetd/src/main/java/dev/ltms/fleet/herdr/PaneLocator.java @@ -192,6 +192,31 @@ public final class PaneLocator { return out; } + /** + * The tab and workspace id of the herdr pane carrying {@code terminal}, from {@code pane.list} + * across every searched daemon — the same call {@link #terminalForPid} scans. Either field is + * {@code null} when no pane matches {@code terminal}, or when the matching pane itself carries + * no tab or workspace id. + */ + public record PaneLocation(String tabId, String workspaceId) { + static final PaneLocation NONE = new PaneLocation(null, null); + } + + /** Resolve {@code terminal} to the tab/workspace id of the pane it occupies. See {@link PaneLocation}. */ + public PaneLocation locate(String terminal) { + if (terminal == null) { + return PaneLocation.NONE; + } + for (HerdrClient herdr : herdrs) { + for (JsonNode pane : herdr.call("pane.list", Map.of()).path("panes")) { + if (terminal.equals(pane.path("terminal_id").asText(null))) { + return new PaneLocation(pane.path("tab_id").asText(null), pane.path("workspace_id").asText(null)); + } + } + } + return PaneLocation.NONE; + } + /** Whether a pane owns one of the scanned pid's ancestors, or the check of it failed outright. */ private enum Ownership { OWNS, DOES_NOT_OWN, UNKNOWN } 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 e93671d1..35625f17 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -9,6 +9,7 @@ import dev.ltms.fleet.auth.Role; import dev.ltms.fleet.guard.GuardException; import dev.ltms.fleet.herdr.Agent; import dev.ltms.fleet.herdr.AgentStatus; +import dev.ltms.fleet.herdr.PaneLocator; import dev.ltms.fleet.metrics.FleetMetrics; import dev.ltms.fleet.metrics.Metrics; import dev.ltms.fleet.inject.MemberPresence; @@ -503,7 +504,7 @@ public final class FleetMcp { // An observer's SEND reaches a pane that cannot otherwise distinguish this // from a human paste (see attributeIfObserver); every other caller's content // passes through unchanged. - String content = attributeIfObserver(caller, str(a, "content")); + String content = attributeIfObserver(caller, str(a, "content"), identity.panes()); String turnId = str(a, "turnId"); String coordId = str(a, "coordId"); if (coordId != null && !coordId.isBlank()) { @@ -987,12 +988,49 @@ public final class FleetMcp { /** * The text an observer's {@code SEND} actually delivers: prefixed with the sender's own * connection-resolved terminal, which the receiving pane cannot otherwise tell apart from a - * human paste. Every other caller's content passes through unchanged. Shared with {@code - * FleetApp}'s REST entry path so both surfaces attribute identically. + * human paste, plus its tab and workspace display label in parentheses when herdr can supply + * either. The terminal id is the only thing in the header a reply can target — a label is + * display-only, is never unique, and is never substituted for it. Every other caller's content + * passes through unchanged. Shared with {@code FleetApp}'s REST entry path so both surfaces + * attribute identically. */ - public static String attributeIfObserver(Principal caller, String content) { - return caller != null && caller.isObserver() - ? "[fleet_send from observer " + caller.terminal() + "]\n" + content : content; + public static String attributeIfObserver(Principal caller, String content, PaneLocator panes) { + if (caller == null || !caller.isObserver()) { + return content; + } + return "[fleet_send from observer " + observerHeader(caller.terminal(), panes) + "]\n" + content; + } + + /** + * {@code terminal}, plus {@code (space "…", tab "…")} for whichever of its workspace/tab + * labels herdr reports — omitted entirely when neither is known, so a degraded lookup still + * reads as the bare id and never as {@code null} or an empty parenthetical. + */ + private static String observerHeader(String terminal, PaneLocator panes) { + String tabLabel = null; + String workspaceLabel = null; + try { + PaneLocator.PaneLocation location = panes.locate(terminal); + tabLabel = location.tabId() == null ? null : panes.tabLabelsByTabId().get(location.tabId()); + workspaceLabel = location.workspaceId() == null ? null + : panes.workspaceLabelsByWorkspaceId().get(location.workspaceId()); + } catch (HerdrException e) { + // degrade to the id alone + } + if (tabLabel == null && workspaceLabel == null) { + return terminal; + } + StringBuilder header = new StringBuilder(terminal).append(" ("); + if (workspaceLabel != null) { + header.append("space \"").append(workspaceLabel).append('"'); + } + if (tabLabel != null && workspaceLabel != null) { + header.append(", "); + } + if (tabLabel != null) { + header.append("tab \"").append(tabLabel).append('"'); + } + return header.append(')').toString(); } /** diff --git a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java index fa5214c6..620187c7 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java @@ -714,7 +714,7 @@ public final class FleetApp { // An observer's SEND reaches a pane that cannot otherwise distinguish this from a human // paste (see FleetMcp#attributeIfObserver, the same rule on the MCP entry path); every // other caller's content passes through unchanged. - content = FleetMcp.attributeIfObserver(caller, content); + content = FleetMcp.attributeIfObserver(caller, content, new PaneLocator(herdr, memberHerdr)); timeout = Math.clamp(timeout, 1, MAX_MESSAGE_TIMEOUT_MS); // Answering a worker's fleet_ask (CB-205): always blocks, and derives the worker from turnId. diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAttributeIfObserverTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAttributeIfObserverTest.java new file mode 100644 index 00000000..1562de12 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAttributeIfObserverTest.java @@ -0,0 +1,93 @@ +package dev.ltms.fleet.mcp; + +import dev.ltms.fleet.auth.Principal; +import dev.ltms.fleet.herdr.FakeHerdr; +import dev.ltms.fleet.herdr.PaneLocator; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link FleetMcp#attributeIfObserver}: the terminal id in an observer's header is always + * present, a tab/workspace label is display-only and additive, and a missing label degrades to + * the id alone rather than rendering {@code null} or an empty parenthetical. + */ +class FleetMcpAttributeIfObserverTest { + + @Test + void aNonObserverPassesContentThroughUnchangedRegardlessOfPanes() { + Principal worker = Principal.worker("term_worker", 1); + assertEquals("hello", FleetMcp.attributeIfObserver(worker, "hello", null)); + } + + @Test + void theTerminalIdIsAlwaysPresentAndNeverReplacedByALabel() { + FakeHerdr herdr = new FakeHerdr() + .withTab("w2", "w2:t8", "shell-tab"); + PaneLocator panes = new PaneLocator(herdr); + Principal observer = Principal.observer("term_shell", 2); + + String result = FleetMcp.attributeIfObserver(observer, "hi", panes); + + assertEquals("[fleet_send from observer term_shell (space \"ltms\", tab \"shell-tab\")]\nhi", result); + } + + /** + * Two tabs sharing one label must still read as two different senders. The label is the same + * on both headers; the id is what differs, and is the only part of either header a reply can + * target. + */ + @Test + void aSharedLabelStillYieldsDistinctHeadersByTerminalId() { + FakeHerdr herdr = new FakeHerdr() + .withTab("w2", "w2:t7", "lead") + .withTab("w2", "w2:t8", "lead"); + PaneLocator panes = new PaneLocator(herdr); + + String fromA = FleetMcp.attributeIfObserver(Principal.observer("term_a", 1), "msg", panes); + String fromShell = FleetMcp.attributeIfObserver(Principal.observer("term_shell", 2), "msg", panes); + + assertEquals("[fleet_send from observer term_a (space \"ltms\", tab \"lead\")]\nmsg", fromA); + assertEquals("[fleet_send from observer term_shell (space \"ltms\", tab \"lead\")]\nmsg", fromShell); + assertNotEquals(fromA, fromShell, "identical labels must not collapse two senders into one header"); + } + + @Test + void anUnknownPaneDegradesToTheIdAloneWithNoLabelAndNoEmptyParens() { + PaneLocator panes = new PaneLocator(new FakeHerdr().withNoPanes()); + Principal observer = Principal.observer("term_ghost", 3); + + String result = FleetMcp.attributeIfObserver(observer, "msg", panes); + + assertEquals("[fleet_send from observer term_ghost]\nmsg", result); + assertFalse(result.contains("null"), "a missing label must never render as the literal \"null\""); + assertFalse(result.contains("()"), "a missing label must never leave an empty parenthetical"); + } + + @Test + void aHerdrFailureDuringTheLabelLookupDegradesToTheIdAlone() { + FakeHerdr herdr = new FakeHerdr().workspaceListFailsWith("unavailable"); + PaneLocator panes = new PaneLocator(herdr); + Principal observer = Principal.observer("term_shell", 4); + + String result = FleetMcp.attributeIfObserver(observer, "msg", panes); + + assertEquals("[fleet_send from observer term_shell]\nmsg", result); + } + + @Test + void onlyTheKnownLabelAppearsWhenTheOtherIsMissing() { + // term_shell's pane sits in workspace "w2" ("ltms"); its tab "w2:t8" carries no seeded + // label, so only the space label appears. + PaneLocator panes = new PaneLocator(new FakeHerdr()); + Principal observer = Principal.observer("term_shell", 5); + + String result = FleetMcp.attributeIfObserver(observer, "msg", panes); + + assertEquals("[fleet_send from observer term_shell (space \"ltms\")]\nmsg", result); + assertTrue(result.contains("term_shell"), "the id must still be present"); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpObserverSendDeliveryTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpObserverSendDeliveryTest.java index 94a628de..e3579fc3 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpObserverSendDeliveryTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpObserverSendDeliveryTest.java @@ -119,7 +119,10 @@ class FleetMcpObserverSendDeliveryTest { @SuppressWarnings("unchecked") Map params = (Map) herdr.lastCall("agent.prompt").params(); - assertEquals("[fleet_send from observer term_shell]\nhi there", params.get("text"), + // term_shell's pane sits in workspace "w2", which FakeHerdr's workspace.list labels + // "ltms"; its tab "w2:t8" carries no label in FakeHerdr's tab.list, so only the space + // label appears. + assertEquals("[fleet_send from observer term_shell (space \"ltms\")]\nhi there", params.get("text"), "the receiving pane must see the sender's own daemon-resolved terminal, never a raw " + "echo of the content and never a client-supplied name"); } diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpObserverSendToLeadDeliveryTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpObserverSendToLeadDeliveryTest.java index 8af9e34d..80315cc2 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpObserverSendToLeadDeliveryTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpObserverSendToLeadDeliveryTest.java @@ -140,7 +140,10 @@ class FleetMcpObserverSendToLeadDeliveryTest { @SuppressWarnings("unchecked") Map params = (Map) herdr.lastCall("agent.prompt").params(); - assertEquals("[fleet_send from observer term_shell]\ncan we split the review?", + // term_shell's pane sits in workspace "w2", which FakeHerdr's workspace.list labels + // "ltms"; its tab "w2:t8" carries no label in FakeHerdr's tab.list, so only the space + // label appears. + assertEquals("[fleet_send from observer term_shell (space \"ltms\")]\ncan we split the review?", params.get("text"), "a lead must see the sender's own daemon-resolved terminal, never a raw echo of " + "the content and never a client-supplied name");