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 c25083d..e042e1e 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -813,7 +813,12 @@ public final class FleetMcp { return error("fleet_reply is for workers only — could not identify the calling worker " + "from the connection"); } - if (content == null) { + // fleetd #302: isBlank, not == null, to match fleet_send's own guard above. MessageService + // .reply now REJECTS blank content, and this handler is a bare BiFunction with no try/catch + // around it — so a whitespace-only fleet_reply would leave here as an uncaught + // IllegalArgumentException instead of this clean tool error. Null and whitespace are the + // same mistake by the caller and must get the same answer. + if (isBlank(content)) { return error("content is required"); } messages.reply(callerTerminal, content); diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java index e06da22..e6acc30 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java @@ -326,6 +326,26 @@ class FleetMcpTest { assertEquals("orphan", drained.getFirst().content()); } + @Test + void replyWithBlankContentIsACleanToolErrorNotAnUncaughtException() { + // fleetd #302: MessageService.reply now REJECTS blank content by throwing. fleet_reply's + // handler is a bare BiFunction with no try/catch around it, so if this guard only checked + // `== null` (as it did), a whitespace-only reply would leave the handler as an uncaught + // IllegalArgumentException instead of a tool error the caller can read. Null and whitespace + // are the same caller mistake and must get the same answer — the sibling fleet_send guard + // has always used isBlank for exactly this reason. + for (String blank : new String[] {null, "", " ", "\n\t"}) { + McpSchema.CallToolResult res = assertDoesNotThrow( + () -> FleetMcp.reply(messages, "term_a", blank), + "blank content must be refused as a tool error, never thrown out of the handler"); + assertEquals(Boolean.TRUE, res.isError(), "blank content is an error result"); + assertTrue(textOf(res).contains("content is required"), + "the error names the missing argument: " + textOf(res)); + } + assertEquals(0, messages.drainReplies("term_a").size(), + "a refused reply must not reach the inbox"); + } + @Test void bridgePollWithTargetDrainsReplies() { // A reply with no open send queues it in the inbox.