616 lines
28 KiB
Java
616 lines
28 KiB
Java
package dev.ltms.bridged.mcp;
|
|
|
|
import dev.ltms.bridged.auth.Principal;
|
|
import dev.ltms.bridged.config.BridgedConfig;
|
|
import dev.ltms.bridged.guard.SubscriptionGuard;
|
|
import dev.ltms.bridged.herdr.AgentControl;
|
|
import dev.ltms.bridged.herdr.FakeHerdr;
|
|
import dev.ltms.bridged.herdr.WorkspaceControl;
|
|
import dev.ltms.bridged.inject.Injector;
|
|
import dev.ltms.bridged.msg.MessageService;
|
|
import dev.ltms.bridged.msg.Rendezvous;
|
|
import dev.ltms.bridged.session.FakeWorktrees;
|
|
import dev.ltms.bridged.session.SessionManager;
|
|
import dev.ltms.bridged.peer.MemberRole;
|
|
import dev.ltms.bridged.inject.MemberPresence;
|
|
import dev.ltms.bridged.session.MemberSession;
|
|
import dev.ltms.bridged.session.WorktreeRequest;
|
|
import dev.ltms.bridged.member.ClaudeCodeLauncher;
|
|
import io.modelcontextprotocol.spec.McpSchema;
|
|
import dev.ltms.bridged.msg.InMemoryReplyInbox;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.concurrent.CompletableFuture;
|
|
import java.util.concurrent.TimeUnit;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
/**
|
|
* Parity tests for the MCP tool adapters — they must produce the same outcomes as the REST routes,
|
|
* since both drive the same {@link MessageService}/{@link Rendezvous}. The MCP wire protocol itself
|
|
* is the SDK's concern; here we test the thin adapter logic directly.
|
|
*/
|
|
class BridgeMcpTest {
|
|
|
|
private static final String T = "term_a";
|
|
|
|
private final FakeHerdr herdr = new FakeHerdr();
|
|
private final AgentControl agents = new AgentControl(herdr);
|
|
private final Rendezvous rendezvous = new Rendezvous();
|
|
private final InMemoryReplyInbox inbox = new InMemoryReplyInbox();
|
|
private final MessageService messages = new MessageService(agents, new Injector(agents), rendezvous, inbox);
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
// CB-520: the inbox only peeks/acks targets it owns.
|
|
inbox.own(T);
|
|
}
|
|
|
|
private static String textOf(McpSchema.CallToolResult r) {
|
|
return ((McpSchema.TextContent) r.content().getFirst()).text();
|
|
}
|
|
|
|
private static ClaudeCodeLauncher workerService(FakeHerdr h, String baseUrl, Set<String> allow) {
|
|
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
|
"ltms-local", baseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null,
|
|
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
|
|
return new ClaudeCodeLauncher(new AgentControl(h), new WorkspaceControl(h),
|
|
new SubscriptionGuard(allow), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> "tok");
|
|
}
|
|
|
|
private static SessionManager sessionManager(FakeHerdr h, String baseUrl, Set<String> allow) {
|
|
return new SessionManager(workerService(h, baseUrl, allow));
|
|
}
|
|
|
|
@Test
|
|
void sendThenReplyRoundTrips() throws Exception {
|
|
// bridge_send blocks; bridge_reply resolves it with the worker's structured answer.
|
|
CompletableFuture<McpSchema.CallToolResult> send = CompletableFuture.supplyAsync(
|
|
() -> BridgeMcp.send(messages, "term_a", "review this", 4000L));
|
|
|
|
// Wait until the send has opened its waiter so the reply resolves it (CB-307: reply now
|
|
// queues in the inbox if no waiter is open, which would break the round-trip).
|
|
long deadline = System.currentTimeMillis() + 3000;
|
|
while (!rendezvous.isWaiting("term_a") && System.currentTimeMillis() < deadline) {
|
|
//noinspection BusyWait
|
|
Thread.sleep(5);
|
|
}
|
|
assertTrue(rendezvous.isWaiting("term_a"), "send should have opened its waiter");
|
|
|
|
McpSchema.CallToolResult reply = BridgeMcp.reply(messages, "term_a", "LGTM");
|
|
assertEquals("delivered", textOf(reply));
|
|
|
|
McpSchema.CallToolResult res = send.get(6, TimeUnit.SECONDS);
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
assertEquals("LGTM", textOf(res));
|
|
}
|
|
|
|
@Test
|
|
void asyncSendReturnsATicketThenPollReportsTheReply() throws Exception {
|
|
// wait:false parity — a ticket is issued, resolved by a reply, and surfaced by bridge_poll.
|
|
McpSchema.CallToolResult accepted = BridgeMcp.sendAsync(messages, "term_a", "do it");
|
|
assertNotEquals(Boolean.TRUE, accepted.isError());
|
|
String out = textOf(accepted);
|
|
assertTrue(out.contains("ticket="), out);
|
|
String ticket = out.substring(out.indexOf("ticket=") + "ticket=".length()).trim();
|
|
|
|
// Wait until the send has opened its waiter before replying (CB-307: reply never errors,
|
|
// so the old retry-on-error pattern no longer works — it would queue instead of resolve).
|
|
long deadline = System.currentTimeMillis() + 3000;
|
|
while (!rendezvous.isWaiting("term_a") && System.currentTimeMillis() < deadline) {
|
|
//noinspection BusyWait
|
|
Thread.sleep(5);
|
|
}
|
|
assertTrue(rendezvous.isWaiting("term_a"), "send should have opened its waiter");
|
|
|
|
McpSchema.CallToolResult reply = BridgeMcp.reply(messages, "term_a", "async LGTM");
|
|
assertEquals("delivered", textOf(reply));
|
|
|
|
// Poll until the async send completes and reports the reply.
|
|
McpSchema.CallToolResult polled = BridgeMcp.poll(messages, ticket, null);
|
|
deadline = System.currentTimeMillis() + 3000;
|
|
while (!textOf(polled).contains("async LGTM") && System.currentTimeMillis() < deadline) {
|
|
//noinspection BusyWait
|
|
Thread.sleep(10);
|
|
polled = BridgeMcp.poll(messages, ticket, null);
|
|
}
|
|
assertEquals("async LGTM", textOf(polled));
|
|
}
|
|
|
|
@Test
|
|
void pollUnknownTicketIsAnError() {
|
|
McpSchema.CallToolResult res = BridgeMcp.poll(messages, "task-999", null);
|
|
assertTrue(res.isError());
|
|
assertTrue(textOf(res).contains("unknown ticket"));
|
|
}
|
|
|
|
@Test
|
|
void sendTimesOutWithAWorkingNote() {
|
|
McpSchema.CallToolResult res = BridgeMcp.send(messages, "term_a", "hi", 120L);
|
|
assertNotEquals(Boolean.TRUE, res.isError(), "a timeout is informational, not a tool error");
|
|
assertTrue(textOf(res).contains("no reply"), "got: " + textOf(res));
|
|
}
|
|
|
|
@Test
|
|
void sendRejectsMissingArgs() {
|
|
assertTrue(BridgeMcp.send(messages, null, "hi", null).isError());
|
|
assertTrue(BridgeMcp.send(messages, "term_a", " ", null).isError());
|
|
}
|
|
|
|
@Test
|
|
void replyWithNoPendingSendIsQueuedNotError() {
|
|
// CB-307: a reply with no open send is now queued in the inbox, not an error.
|
|
McpSchema.CallToolResult res = BridgeMcp.reply(messages, "term_a", "orphan");
|
|
assertNotEquals(Boolean.TRUE, res.isError(), "a queued reply is not an error");
|
|
assertEquals("delivered", textOf(res));
|
|
|
|
// The reply is drainable by target.
|
|
var drained = messages.drainReplies("term_a");
|
|
assertEquals(1, drained.size());
|
|
assertEquals("orphan", drained.getFirst().content());
|
|
}
|
|
|
|
@Test
|
|
void bridgePollWithTargetDrainsReplies() {
|
|
// A reply with no open send queues it in the inbox.
|
|
BridgeMcp.reply(messages, "term_a", "queued-msg");
|
|
|
|
// bridge_poll with target drains the inbox.
|
|
McpSchema.CallToolResult res = BridgeMcp.poll(messages, null, "term_a");
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
String text = textOf(res);
|
|
assertTrue(text.contains("queued-msg"), "the drained reply should appear in the result");
|
|
|
|
// Second drain returns empty.
|
|
McpSchema.CallToolResult empty = BridgeMcp.poll(messages, null, "term_a");
|
|
assertEquals("[]", textOf(empty));
|
|
}
|
|
|
|
@Test
|
|
void askThenAnswerRoundTrips() throws Exception {
|
|
// The primary delegates and blocks; wait until its waiter is open before the worker asks.
|
|
CompletableFuture<McpSchema.CallToolResult> send = CompletableFuture.supplyAsync(
|
|
() -> BridgeMcp.send(messages, "term_a", "do X", 5000L));
|
|
long deadline = System.currentTimeMillis() + 3000;
|
|
while (!rendezvous.isWaiting("term_a") && System.currentTimeMillis() < deadline) {
|
|
//noinspection BusyWait
|
|
Thread.sleep(5);
|
|
}
|
|
assertTrue(rendezvous.isWaiting("term_a"), "the send must be waiting for the ask to surface to");
|
|
|
|
// The worker asks mid-turn; the call blocks for the primary's answer.
|
|
CompletableFuture<McpSchema.CallToolResult> ask = CompletableFuture.supplyAsync(
|
|
() -> BridgeMcp.ask(messages, "term_a", "which config?", 5000L));
|
|
|
|
// The primary's send unblocks with the question and a turnId to answer on.
|
|
McpSchema.CallToolResult q = send.get(6, TimeUnit.SECONDS);
|
|
assertNotEquals(Boolean.TRUE, q.isError());
|
|
String qt = textOf(q);
|
|
assertTrue(qt.contains("[question]"), qt);
|
|
String afterMarker = qt.substring(qt.indexOf("turnId=\"") + "turnId=\"".length());
|
|
String turnId = afterMarker.substring(0, afterMarker.indexOf('"'));
|
|
|
|
// The primary answers via bridge_send(turnId); this blocks again for the worker's reply.
|
|
CompletableFuture<McpSchema.CallToolResult> answer = CompletableFuture.supplyAsync(
|
|
() -> BridgeMcp.answer(messages, turnId, "config.yaml", 5000L));
|
|
|
|
// The worker's ask returns the answer — it resumes the same turn.
|
|
assertEquals("config.yaml", textOf(ask.get(6, TimeUnit.SECONDS)));
|
|
|
|
// The resumed worker replies, resolving the answering send (wait for the reopened waiter).
|
|
deadline = System.currentTimeMillis() + 3000;
|
|
while (!rendezvous.isWaiting("term_a") && System.currentTimeMillis() < deadline) {
|
|
//noinspection BusyWait
|
|
Thread.sleep(5);
|
|
}
|
|
assertTrue(rendezvous.isWaiting("term_a"), "the answer should have reopened a waiter");
|
|
McpSchema.CallToolResult reply = BridgeMcp.reply(messages, "term_a", "done");
|
|
assertEquals("delivered", textOf(reply));
|
|
assertEquals("done", textOf(answer.get(6, TimeUnit.SECONDS)));
|
|
}
|
|
|
|
@Test
|
|
void askFromANonWorkerConnectionIsAnError() {
|
|
McpSchema.CallToolResult res = BridgeMcp.ask(messages, null, "which config?", 500L);
|
|
assertTrue(res.isError());
|
|
assertTrue(textOf(res).contains("workers only"), textOf(res));
|
|
}
|
|
|
|
@Test
|
|
void answerToAStaleTurnIsAnError() {
|
|
McpSchema.CallToolResult res = BridgeMcp.answer(messages, "term_a#999", "too late", 500L);
|
|
assertTrue(res.isError());
|
|
assertTrue(textOf(res).contains("no longer open"), textOf(res));
|
|
}
|
|
|
|
@Test
|
|
void spawnReturnsTheNewWorkersSessionAndPane() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
SessionManager sm = sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw"));
|
|
McpSchema.CallToolResult res = BridgeMcp.spawn(sm, null);
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
String out = textOf(res);
|
|
assertTrue(out.contains("\"sessionId\":\"term_new_1\""), out);
|
|
// CB-519: the "paneId" wire field now carries the host-unique opaque id, not the herdr pane.
|
|
MemberSession s = sm.roster().getFirst();
|
|
assertTrue(out.contains("\"paneId\":\"" + s.paneId() + "\""), out);
|
|
assertNotEquals("w9:pRoot_1", s.paneId(), "the id is decoupled from the herdr pane coordinate");
|
|
assertTrue(out.contains("\"status\":\"spawning\""), out);
|
|
}
|
|
|
|
@Test
|
|
void spawnRejectsAnOffAllowlistProfileWithoutTouchingHerdr() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
McpSchema.CallToolResult res =
|
|
BridgeMcp.spawn(sessionManager(h, "https://api.anthropic.com", Set.of("gx00.gw")), null);
|
|
assertTrue(res.isError());
|
|
assertTrue(textOf(res).contains("subscription boundary"));
|
|
assertFalse(h.called("agent.start"), "the guard must block before any spawn");
|
|
}
|
|
|
|
@Test
|
|
void spawnRejectsAnUnknownProfileAsAnError() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
McpSchema.CallToolResult res =
|
|
BridgeMcp.spawn(sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), "nope");
|
|
assertTrue(res.isError());
|
|
assertTrue(textOf(res).contains("unknown worker profile"), textOf(res));
|
|
}
|
|
|
|
@Test
|
|
void spawnPassesTheRequestedCwdToTheWorker() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
McpSchema.CallToolResult res = BridgeMcp.spawn(
|
|
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, null, "/req/dir", null, null, null);
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
// Protocol 19: the requested cwd roots the worker's pane at creation (tab.create).
|
|
@SuppressWarnings("unchecked")
|
|
Map<String, Object> create = (Map<String, Object>) h.lastCall("tab.create").params();
|
|
assertEquals("/req/dir", create.get("cwd"));
|
|
}
|
|
|
|
@Test
|
|
void profilesListsConfiguredProfilesAndDefault() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
McpSchema.CallToolResult res = BridgeMcp.profiles(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
String out = textOf(res);
|
|
assertTrue(out.contains("ltms-local"), out);
|
|
assertTrue(out.contains("\"default\":\"ltms-local\""), out);
|
|
}
|
|
|
|
@Test
|
|
void listReportsTrackedWorkers() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt");
|
|
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), worktrees);
|
|
MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", "term_primary",
|
|
new WorktreeRequest("cb-304", null));
|
|
|
|
McpSchema.CallToolResult res = BridgeMcp.listFleet(
|
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, Map.of(), "");
|
|
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
String out = textOf(res);
|
|
assertTrue(out.contains("\"sessionId\":\"" + s.terminalId() + "\""), out);
|
|
assertTrue(out.contains("\"paneId\":\"" + s.paneId() + "\""), out);
|
|
assertTrue(out.contains("\"profile\":\"ltms-local\""), out);
|
|
assertTrue(out.contains("\"state\":\"spawning\""), out);
|
|
assertTrue(out.contains("\"worktree\":\"" + s.worktree() + "\""), out);
|
|
assertTrue(out.contains("\"branch\":\"" + s.branch() + "\""), out);
|
|
assertTrue(out.contains("\"owner\":\"term_primary\""), out);
|
|
assertTrue(out.contains("\"liveStatus\":\"unknown\""), out);
|
|
}
|
|
|
|
@Test
|
|
void listReportsLeadsAndFlagsTheCallersOwnRow() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
SessionManager sessions = new SessionManager(
|
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
|
|
|
McpSchema.CallToolResult res = BridgeMcp.listFleet(
|
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions,
|
|
Map.of("term_me", "opus-5.0", "term_peer", "gpt-sol-5.6"), "term_me");
|
|
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
String out = textOf(res);
|
|
assertTrue(out.contains("\"name\":\"opus-5.0\""), out);
|
|
assertTrue(out.contains("\"name\":\"gpt-sol-5.6\""), out);
|
|
assertTrue(out.contains("\"sessionId\":\"term_peer\""), out);
|
|
// The caller's own row is flagged, and only the caller's — a peer must be distinguishable
|
|
// from self without a second bridge_whoami call.
|
|
assertEquals(1, out.split("\"self\":true", -1).length - 1, out);
|
|
assertTrue(out.indexOf("term_me") < out.indexOf("\"self\":true"), out);
|
|
}
|
|
|
|
@Test
|
|
void listReportsBothHalvesEvenWhenEmpty() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
SessionManager sessions = new SessionManager(
|
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
|
|
|
McpSchema.CallToolResult res = BridgeMcp.listFleet(
|
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, Map.of(), "");
|
|
|
|
// An absent "leads" key is what made an empty member roster read as "no peers" (CB-535).
|
|
String out = textOf(res);
|
|
assertTrue(out.contains("\"leads\":[]"), out);
|
|
assertTrue(out.contains("\"members\":[]"), out);
|
|
}
|
|
|
|
@Test
|
|
void listReportsALeadHerdrCannotSeeAsUnknown() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
SessionManager sessions = new SessionManager(
|
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
|
|
|
McpSchema.CallToolResult res = BridgeMcp.listFleet(
|
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions,
|
|
Map.of("term_ghost", "gone-away"), "term_me");
|
|
|
|
// Reported, not hidden: an unreachable peer is exactly what a would-be sender needs to see.
|
|
String out = textOf(res);
|
|
assertTrue(out.contains("\"name\":\"gone-away\""), out);
|
|
assertTrue(out.contains("\"status\":\"unknown\""), out);
|
|
}
|
|
|
|
@Test
|
|
void stopTearsDownAWorkerByPane() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
McpSchema.CallToolResult res = BridgeMcp.stop(
|
|
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), "w9:pW");
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
assertEquals("stopped w9:pW", textOf(res));
|
|
assertTrue(h.called("pane.close"));
|
|
}
|
|
|
|
@Test
|
|
void stopRequiresAPaneId() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
assertTrue(BridgeMcp.stop(
|
|
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), " ").isError());
|
|
}
|
|
|
|
@Test
|
|
void bridgeAckReturnsConfirmationForValidArgs() {
|
|
McpSchema.CallToolResult res = BridgeMcp.ack(messages, "term_a", "msg-1");
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
assertTrue(textOf(res).contains("msg-1"), "response should mention the msgId");
|
|
}
|
|
|
|
@Test
|
|
void bridgeAckRejectsMissingArgs() {
|
|
assertTrue(BridgeMcp.ack(messages, null, "msg-1").isError());
|
|
assertTrue(BridgeMcp.ack(messages, "term_a", null).isError());
|
|
assertTrue(BridgeMcp.ack(messages, " ", "msg-1").isError());
|
|
}
|
|
|
|
@Test
|
|
void bridgeAckRemovesSpecificReply() {
|
|
// Queue a reply and capture its msgId.
|
|
BridgeMcp.reply(messages, "term_a", "orphan");
|
|
var before = messages.drainReplies("term_a");
|
|
assertEquals(1, before.size(), "one reply in the inbox");
|
|
String msgId = before.getFirst().msgId();
|
|
|
|
// Publish the same reply again and ack it via bridge_ack surface.
|
|
BridgeMcp.reply(messages, "term_a", "orphan-again");
|
|
var peeked = messages.drainReplies("term_a");
|
|
assertEquals(1, peeked.size(), "one fresh reply in the inbox");
|
|
|
|
// ackReply works (no-op since published with a different UUID, but callable).
|
|
assertDoesNotThrow(() -> messages.ackReply("term_a", msgId));
|
|
}
|
|
|
|
@Test
|
|
void spawnedMembersAreMarkedPresent() {
|
|
Principal worker = Principal.worker("term_worker", 200);
|
|
Principal architect = Principal.architect("lead-designer", "term_design", 400);
|
|
MemberPresence presence = new MemberPresence();
|
|
|
|
BridgeMcp.markSpawnedMemberPresent(worker, presence);
|
|
BridgeMcp.markSpawnedMemberPresent(architect, presence);
|
|
|
|
assertTrue(presence.isPresent("term_worker"));
|
|
assertTrue(presence.isPresent("term_design"));
|
|
}
|
|
|
|
@Test
|
|
void nonMembersAreNotMarkedPresent() {
|
|
Principal lead = Principal.leader("opus", "term_lead", 100);
|
|
MemberPresence presence = new MemberPresence();
|
|
|
|
BridgeMcp.markSpawnedMemberPresent(lead, presence);
|
|
BridgeMcp.markSpawnedMemberPresent(Principal.anonymous(), presence);
|
|
|
|
assertFalse(presence.isPresent("term_lead"));
|
|
}
|
|
|
|
@Test
|
|
void statusReportsLiveAgentStatus() {
|
|
FakeHerdr blocked = new FakeHerdr().agentStatus("blocked");
|
|
AgentControl blockedAgents = new AgentControl(blocked);
|
|
McpSchema.CallToolResult res = BridgeMcp.status(
|
|
new MessageService(blockedAgents, new Injector(blockedAgents), rendezvous), "term_a");
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
assertEquals("blocked", textOf(res));
|
|
}
|
|
|
|
// --- bridge_whoami: the caller's own identity, so an agent never has to guess its role -------
|
|
|
|
@Test
|
|
void whoamiReportsThePrimaryAsPrimaryAndNothingElse() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
McpSchema.CallToolResult res = BridgeMcp.whoami(
|
|
Principal.primary(100), sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
|
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
String out = textOf(res);
|
|
assertTrue(out.contains("\"role\":\"primary\""), out);
|
|
// The primary owns no session — leaking a sessionId here would invite it to reply as one.
|
|
assertFalse(out.contains("sessionId"), out);
|
|
}
|
|
|
|
@Test
|
|
void whoamiReportsAWorkerWithItsRegisteredSession() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt");
|
|
SessionManager sessions = new SessionManager(
|
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), worktrees);
|
|
MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", "term_primary",
|
|
new WorktreeRequest("cb-517", null));
|
|
|
|
McpSchema.CallToolResult res = BridgeMcp.whoami(Principal.worker(s.terminalId(), 200), sessions);
|
|
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
String out = textOf(res);
|
|
assertTrue(out.contains("\"role\":\"worker\""), out);
|
|
assertTrue(out.contains("\"sessionId\":\"" + s.terminalId() + "\""), out);
|
|
assertTrue(out.contains("\"profile\":\"ltms-local\""), out);
|
|
assertTrue(out.contains("\"worktree\":\"" + s.worktree() + "\""), out);
|
|
assertTrue(out.contains("\"branch\":\"" + s.branch() + "\""), out);
|
|
assertTrue(out.contains("\"owner\":\"term_primary\""), out);
|
|
}
|
|
|
|
/**
|
|
* A worker the registry has no record of — it outlived a daemon restart — must still learn the
|
|
* load-bearing fact. Degrading to "I don't know who you are" would put it back to guessing,
|
|
* which is the failure this tool exists to remove.
|
|
*/
|
|
@Test
|
|
void whoamiStillReportsWorkerRoleWhenTheSessionIsUnregistered() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
McpSchema.CallToolResult res = BridgeMcp.whoami(Principal.worker("term_orphan", 200),
|
|
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
|
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
String out = textOf(res);
|
|
assertTrue(out.contains("\"role\":\"worker\""), out);
|
|
assertTrue(out.contains("\"sessionId\":\"term_orphan\""), out);
|
|
assertFalse(out.contains("profile"), out); // nothing invented for a session we don't track
|
|
}
|
|
|
|
/**
|
|
* CB-548: an architect reports its role and which gateway-local slot its pane is bound to —
|
|
* the same shape as a lead, under the architect key, so it can tell a peer where to reach it.
|
|
*/
|
|
@Test
|
|
void whoamiReportsAnArchitectWithItsSlotAndPane() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
McpSchema.CallToolResult res = BridgeMcp.whoami(
|
|
Principal.architect("lead-designer", "term_design", 400),
|
|
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
|
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
String out = textOf(res);
|
|
assertTrue(out.contains("\"role\":\"architect\""), out);
|
|
assertTrue(out.contains("\"architect\":\"lead-designer\""), out);
|
|
assertTrue(out.contains("\"sessionId\":\"term_design\""), out);
|
|
}
|
|
|
|
/**
|
|
* CB-548: an architect SEND delegates as its own pane (recording the per-target delegation) but
|
|
* must NEVER become the legacy singleton "primary" fallback — the per-target map does not cure
|
|
* the singleton, so an architect left there would draw no-delegation inbox nudges meant for a
|
|
* primary. Only PRIMARY callers (the unnamed primary and named leads alike) may claim it, and
|
|
* the decision keys on the resolved role, not name/kind sniffing.
|
|
*/
|
|
@Test
|
|
void architectSendDoesNotClaimThePrimarySingletonButALeadSendStillCan() {
|
|
// Architect SEND: does not change the legacy primary fallback.
|
|
PrimaryRegistry reg = new PrimaryRegistry(null);
|
|
BridgeMcp.recordPrimarySingleton(reg, "term_design", Principal.architect("design", "term_design", 400));
|
|
assertTrue(reg.primaryTerminal().isEmpty(),
|
|
"an architect must never become the legacy primary fallback");
|
|
|
|
// Lead SEND (a named PRIMARY) still claims it — preserved from CB-530/CB-532.
|
|
PrimaryRegistry leadReg = new PrimaryRegistry(null);
|
|
BridgeMcp.recordPrimarySingleton(leadReg, "term_lead_opus", Principal.leader("opus", "term_lead_opus", 100));
|
|
assertEquals("term_lead_opus", leadReg.primaryTerminal().orElseThrow(),
|
|
"a named lead is a primary and may claim the fallback");
|
|
|
|
// Unnamed primary likewise.
|
|
PrimaryRegistry primaryReg = new PrimaryRegistry(null);
|
|
BridgeMcp.recordPrimarySingleton(primaryReg, "term_p", Principal.primary(50));
|
|
assertEquals("term_p", primaryReg.primaryTerminal().orElseThrow(),
|
|
"an unnamed primary may claim the fallback");
|
|
|
|
// A null caller (legacy/no-auth path) records nothing.
|
|
PrimaryRegistry legacy = new PrimaryRegistry(null);
|
|
BridgeMcp.recordPrimarySingleton(legacy, "term_x", null);
|
|
assertTrue(legacy.primaryTerminal().isEmpty(), "no caller means nothing is recorded");
|
|
}
|
|
|
|
// --- member taxonomy (CB-557) ---------------------------------------------------------
|
|
|
|
@Test
|
|
void bridgeListReportsMembersNotWorkersAndCarriesEachRole() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
|
sessions.acquire("ltms-local", MemberRole.REVIEWER, null, "/caller/proj", "term_primary", null);
|
|
|
|
McpSchema.CallToolResult res = BridgeMcp.listFleet(
|
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, Map.of(), "");
|
|
|
|
String out = textOf(res);
|
|
assertTrue(out.contains("\"members\":"), "the roster half is named members: " + out);
|
|
assertFalse(out.contains("\"workers\":"), "the old key must be gone: " + out);
|
|
assertTrue(out.contains("\"role\":\"reviewer\""), out);
|
|
}
|
|
|
|
/**
|
|
* Role and profile are separate axes, so the roster has to report both. Two members on one
|
|
* backend may still be allowed to do entirely different things.
|
|
*/
|
|
@Test
|
|
void aRosterRowCarriesBothItsRoleAndItsProfile() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
|
sessions.acquire("ltms-local", MemberRole.DEV, null, "/caller/proj", "term_primary", null);
|
|
sessions.acquire("ltms-local", MemberRole.REVIEWER, null, "/caller/proj", "term_primary", null);
|
|
|
|
String out = textOf(BridgeMcp.listFleet(
|
|
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, Map.of(), ""));
|
|
|
|
assertTrue(out.contains("\"role\":\"dev\""), out);
|
|
assertTrue(out.contains("\"role\":\"reviewer\""), out);
|
|
assertEquals(2, out.split("\"profile\":\"ltms-local\"", -1).length - 1,
|
|
"both members share one profile — that is the point: " + out);
|
|
}
|
|
|
|
@Test
|
|
void spawnDefaultsTheRoleToDev() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
McpSchema.CallToolResult res = BridgeMcp.spawn(
|
|
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, null, null, null, null, null);
|
|
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
assertTrue(textOf(res).contains("\"role\":\"dev\""), textOf(res));
|
|
}
|
|
|
|
@Test
|
|
void spawnAcceptsAnExplicitRole() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
McpSchema.CallToolResult res = BridgeMcp.spawn(
|
|
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, "architect",
|
|
null, null, null, null);
|
|
|
|
assertNotEquals(Boolean.TRUE, res.isError());
|
|
assertTrue(textOf(res).contains("\"role\":\"architect\""), textOf(res));
|
|
}
|
|
|
|
@Test
|
|
void spawnRejectsAnUnknownRoleAndNamesTheValidOnes() {
|
|
FakeHerdr h = new FakeHerdr();
|
|
McpSchema.CallToolResult res = BridgeMcp.spawn(
|
|
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, "worker",
|
|
null, null, null, null);
|
|
|
|
assertEquals(Boolean.TRUE, res.isError());
|
|
assertTrue(textOf(res).contains("architect, dev, reviewer"), textOf(res));
|
|
}
|
|
}
|