diff --git a/src/loop/bridge-guidance.ts b/src/loop/bridge-guidance.ts index 3c82c3d..4a3bed1 100644 --- a/src/loop/bridge-guidance.ts +++ b/src/loop/bridge-guidance.ts @@ -1,7 +1,17 @@ import { BRIDGE_SERVER, CLAUDE_CHANNEL_USER } from "./bridge-constants"; import type { Agent } from "./types"; +export type BridgeTool = "bridge_status" | "receive_messages" | "send_message"; + const bridgeTargetLiteral = (agent: Agent): string => `target: "${agent}"`; +const codexBridgeToolName = (tool: BridgeTool): string => + `mcp__${BRIDGE_SERVER.replaceAll("-", "_")}__${tool}`; + +export const bridgeToolName = (agent: Agent, tool: BridgeTool): string => + agent === "claude" ? tool : codexBridgeToolName(tool); + +export const quotedBridgeTool = (agent: Agent, tool: BridgeTool): string => + `"${bridgeToolName(agent, tool)}"`; export const bridgeStatusStuckGuidance = 'Use "bridge_status" only when direct delivery appears stuck.'; @@ -9,9 +19,6 @@ export const bridgeStatusStuckGuidance = export const receiveMessagesStuckGuidance = 'Use "bridge_status" or "receive_messages" only if delivery looks stuck.'; -export const sendToClaudeGuidance = (): string => - `Use "send_message" with ${bridgeTargetLiteral("claude")} for Claude-facing messages, not a human-facing message.`; - export const sendProactiveCodexGuidance = (): string => `Use "send_message" with ${bridgeTargetLiteral("codex")} for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.`; diff --git a/src/loop/paired-loop.ts b/src/loop/paired-loop.ts index e0f9bf4..1b1bfbd 100644 --- a/src/loop/paired-loop.ts +++ b/src/loop/paired-loop.ts @@ -3,6 +3,7 @@ import { acknowledgeBridgeDelivery, readNextPendingBridgeMessage, } from "./bridge-dispatch"; +import { quotedBridgeTool } from "./bridge-guidance"; import { formatCodexBridgeMessage } from "./bridge-message-format"; import { getLastClaudeSessionId } from "./claude-sdk-server"; import { getLastCodexThreadId } from "./codex-app-server"; @@ -100,30 +101,31 @@ const bridgeGuidance = (agent: Agent): string => { const target = agent === "claude" ? "codex" : "claude"; return [ "Paired mode:", - `You are in a persistent Claude/Codex pair. Use the MCP tool "send_message" with ${bridgeTargetLiteral(target)} when you want ${peer} to act, review, or answer.`, - 'Do not ask the human to relay messages between agents or answer the human on the other agent\'s behalf. Use "bridge_status" only if delivery looks stuck.', - 'Use "receive_messages" only if "bridge_status" shows pending messages addressed to you and direct delivery looks stuck.', + `You are in a persistent Claude/Codex pair. Use the MCP tool ${quotedBridgeTool(agent, "send_message")} with ${bridgeTargetLiteral(target)} when you want ${peer} to act, review, or answer.`, + `Do not ask the human to relay messages between agents or answer the human on the other agent's behalf. Use ${quotedBridgeTool(agent, "bridge_status")} only if delivery looks stuck.`, + `Use ${quotedBridgeTool(agent, "receive_messages")} only if ${quotedBridgeTool(agent, "bridge_status")} shows pending messages addressed to you and direct delivery looks stuck.`, ].join("\n"); }; -const bridgeToolGuidance = [ - 'You can use the MCP tools "send_message", "bridge_status", and "receive_messages" for direct Claude/Codex coordination.', - 'Only use "bridge_status" or "receive_messages" when delivery looks stuck.', - "Do not ask the human to relay messages between agents.", -].join("\n"); +const bridgeToolGuidance = (agent: Agent): string => + [ + `You can use the MCP tools ${quotedBridgeTool(agent, "send_message")}, ${quotedBridgeTool(agent, "bridge_status")}, and ${quotedBridgeTool(agent, "receive_messages")} for direct Claude/Codex coordination.`, + `Only use ${quotedBridgeTool(agent, "bridge_status")} or ${quotedBridgeTool(agent, "receive_messages")} when delivery looks stuck.`, + "Do not ask the human to relay messages between agents.", + ].join("\n"); const reviewDeliveryGuidance = (reviewer: Agent, opts: Options): string => { if (reviewer === opts.agent) { return "If review is needed, keep the actionable notes in your review body before the final review signal."; } - return `If review is needed, send the actionable notes to ${capitalize(opts.agent)} with "send_message" using ${bridgeTargetLiteral(opts.agent)} before returning your final review signal.`; + return `If review is needed, send the actionable notes to ${capitalize(opts.agent)} with ${quotedBridgeTool(reviewer, "send_message")} using ${bridgeTargetLiteral(opts.agent)} before returning your final review signal.`; }; const reviewToolGuidance = (reviewer: Agent, opts: Options): string => reviewer === opts.agent ? "Use the review body itself for follow-up notes. No bridge message is needed for a self-review." - : bridgeToolGuidance; + : bridgeToolGuidance(reviewer); const formatSelfReviewNotes = ( failures: ReviewFailure[], @@ -158,22 +160,26 @@ const forwardBridgePrompt = ({ }: { message: string; source: Agent; -}): string => - (source === "claude" - ? [ - formatCodexBridgeMessage(source, message), - "Treat this as direct agent-to-agent coordination. Do not reply to the human.", - 'Send a message to the other agent with "send_message" only when you have something useful for them to act on.', - "Do not acknowledge receipt without new information.", - ] - : [ - `Message from ${capitalize(source)} via the loop bridge:`, - message.trim(), - "Treat this as direct agent-to-agent coordination. Do not reply to the human.", - 'Send a message to the other agent with "send_message" only when you have something useful for them to act on.', - "Do not acknowledge receipt without new information.", - ] +}): string => { + const agent = source === "claude" ? "codex" : "claude"; + const replyGuidance = `Send a message to the other agent with ${quotedBridgeTool(agent, "send_message")} only when you have something useful for them to act on.`; + return ( + source === "claude" + ? [ + formatCodexBridgeMessage(source, message), + "Treat this as direct agent-to-agent coordination. Do not reply to the human.", + replyGuidance, + "Do not acknowledge receipt without new information.", + ] + : [ + `Message from ${capitalize(source)} via the loop bridge:`, + message.trim(), + "Treat this as direct agent-to-agent coordination. Do not reply to the human.", + replyGuidance, + "Do not acknowledge receipt without new information.", + ] ).join("\n\n"); +}; const updateIds = (state: PairedState): void => { const next = touchRunManifest( diff --git a/src/loop/tmux.ts b/src/loop/tmux.ts index 7df54a4..fb73716 100644 --- a/src/loop/tmux.ts +++ b/src/loop/tmux.ts @@ -12,11 +12,7 @@ import { legacyClaudeChannelServerName, resolveClaudeChannelServerName, } from "./bridge-config"; -import { - receiveMessagesStuckGuidance, - sendProactiveCodexGuidance, - sendToClaudeGuidance, -} from "./bridge-guidance"; +import { type BridgeTool, quotedBridgeTool } from "./bridge-guidance"; import { getCodexAppServerUrl, getLastCodexThreadId } from "./codex-app-server"; import { CODEX_TMUX_PROXY_SUBCOMMAND, @@ -155,6 +151,11 @@ const appendProofPrompt = (parts: string[], proof: string): void => { parts.push(`Proof requirements:\n${trimmed}`); }; +const quotedClaudeTmuxBridgeTool = ( + serverName: string, + tool: BridgeTool +): string => `"mcp__${serverName}__${tool}"`; + const pairedBridgeGuidance = ( agent: Agent, _runId: string, @@ -162,13 +163,15 @@ const pairedBridgeGuidance = ( ): string => { if (agent === "claude") { return [ - `Your bridge MCP server is "${serverName}". All bridge tool calls must use the mcp__${serverName}__ prefix.`, - sendProactiveCodexGuidance(), - receiveMessagesStuckGuidance, + `Your bridge MCP server is "${serverName}". Use ${quotedClaudeTmuxBridgeTool(serverName, "send_message")} with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.`, + `Use ${quotedClaudeTmuxBridgeTool(serverName, "bridge_status")} or ${quotedClaudeTmuxBridgeTool(serverName, "receive_messages")} only if delivery looks stuck.`, ].join("\n"); } - return [sendToClaudeGuidance(), receiveMessagesStuckGuidance].join("\n"); + return [ + `Use the MCP tool ${quotedBridgeTool(agent, "send_message")} with target: "claude" for Claude-facing messages, not a human-facing message.`, + `Use ${quotedBridgeTool(agent, "bridge_status")} or ${quotedBridgeTool(agent, "receive_messages")} only if delivery looks stuck.`, + ].join("\n"); }; const pairedWorkflowGuidance = (opts: Options, agent: Agent): string => { @@ -202,7 +205,7 @@ const buildPrimaryPrompt = ( const parts = [ `Agent-to-agent pair programming: you are the primary ${capitalize(opts.agent)} agent for this run.`, `Task:\n${task.trim()}`, - `Your peer is ${peer}. Do the initial pass yourself, then use "send_message" when you want review or targeted help from ${peer}.`, + `Your peer is ${peer}. Do the initial pass yourself, then use ${quotedBridgeTool(opts.agent, "send_message")} when you want review or targeted help from ${peer}.`, ]; appendProofPrompt(parts, opts.proof); parts.push(SPAWN_TEAM_WITH_WORKTREE_ISOLATION); @@ -245,7 +248,7 @@ const buildInteractivePrimaryPrompt = ( const parts = [ `Agent-to-agent pair programming: you are the primary ${capitalize(opts.agent)} agent for this run.`, "No task has been assigned yet.", - `Your peer is ${peer}. Use "send_message" for review or help once the human gives you a task.`, + `Your peer is ${peer}. Use ${quotedBridgeTool(opts.agent, "send_message")} for review or help once the human gives you a task.`, ]; appendProofPrompt(parts, opts.proof); parts.push( diff --git a/tests/loop/bridge-guidance.test.ts b/tests/loop/bridge-guidance.test.ts new file mode 100644 index 0000000..53632f9 --- /dev/null +++ b/tests/loop/bridge-guidance.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +import { + bridgeToolName, + quotedBridgeTool, +} from "../../src/loop/bridge-guidance"; + +test("bridgeToolName namespaces Codex bridge tools only", () => { + expect(bridgeToolName("codex", "send_message")).toBe( + "mcp__loop_bridge__send_message" + ); + expect(bridgeToolName("codex", "bridge_status")).toBe( + "mcp__loop_bridge__bridge_status" + ); + expect(bridgeToolName("claude", "send_message")).toBe("send_message"); +}); + +test("quotedBridgeTool wraps the resolved bridge tool name", () => { + expect(quotedBridgeTool("codex", "send_message")).toBe( + '"mcp__loop_bridge__send_message"' + ); + expect(quotedBridgeTool("claude", "receive_messages")).toBe( + '"receive_messages"' + ); +}); diff --git a/tests/loop/paired-loop.test.ts b/tests/loop/paired-loop.test.ts index a0a13ab..60ab211 100644 --- a/tests/loop/paired-loop.test.ts +++ b/tests/loop/paired-loop.test.ts @@ -777,6 +777,7 @@ test("runPairedLoop delivers peer messages back to the primary agent", async () "Please verify the implementation details." ); expect(calls[1]?.prompt).toContain("Do not reply to the human."); + expect(calls[1]?.prompt).toContain('"mcp__loop_bridge__send_message"'); expect(calls[2]?.agent).toBe("claude"); expect(calls[2]?.prompt).toContain( "Message from Codex via the loop bridge:" @@ -862,7 +863,7 @@ test("runPairedLoop preserves claudex reviewers in paired mode", async () => { "concrete file paths, commands, and code locations that must change" ); expect(reviewPrompts[1]?.prompt).toContain( - 'send the actionable notes to Claude with "send_message" using target: "claude"' + 'send the actionable notes to Claude with "mcp__loop_bridge__send_message" using target: "claude"' ); }); }); diff --git a/tests/loop/tmux.test.ts b/tests/loop/tmux.test.ts index 697d17e..e592db7 100644 --- a/tests/loop/tmux.test.ts +++ b/tests/loop/tmux.test.ts @@ -792,19 +792,21 @@ test("tmux prompts keep the paired review workflow explicit", () => { "create a draft PR or send a follow-up commit to the existing PR" ); expect(primaryPrompt).not.toContain("Wait briefly if it arrives"); - expect(primaryPrompt).toContain( - 'Use "send_message" with target: "claude" for Claude-facing messages' - ); + expect(primaryPrompt).toContain('"mcp__loop_bridge__send_message"'); expect(primaryPrompt).toContain("worktree isolation"); expect(peerPrompt).toContain("You are the reviewer/support agent."); expect(peerPrompt).toContain("Do not take over the task or create the PR"); expect(peerPrompt).toContain("Wait for Codex to send you a targeted request"); expect(peerPrompt).not.toContain('"reply"'); expect(peerPrompt).toContain( - 'Use "send_message" with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.' + 'Use "mcp__loop-bridge-repo-123-1__send_message" with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.' ); expect(primaryPrompt).not.toContain("mcp__loop-bridge-repo-123-1__ prefix"); - expect(peerPrompt).toContain("mcp__loop-bridge-repo-123-1__ prefix"); + expect(peerPrompt).not.toContain("mcp__loop-bridge-repo-123-1__ prefix"); + expect(peerPrompt).toContain('"mcp__loop-bridge-repo-123-1__bridge_status"'); + expect(peerPrompt).toContain( + '"mcp__loop-bridge-repo-123-1__receive_messages"' + ); }); test("interactive tmux prompts tell both agents to wait for the human", () => { @@ -831,9 +833,7 @@ test("interactive tmux prompts tell both agents to wait for the human", () => { expect(primaryPrompt).toContain("If the human asks for plan mode"); expect(primaryPrompt).toContain("ask Claude for a plan review"); expect(primaryPrompt).toContain("ask the human to review the plan"); - expect(primaryPrompt).toContain( - 'Use "send_message" with target: "claude" for Claude-facing messages' - ); + expect(primaryPrompt).toContain('"mcp__loop_bridge__send_message"'); expect(primaryPrompt).toContain("worktree isolation"); expect(peerPrompt).toContain("No task has been assigned yet."); expect(peerPrompt).toContain( @@ -843,13 +843,18 @@ test("interactive tmux prompts tell both agents to wait for the human", () => { expect(peerPrompt).toContain("human clearly assigns you separate work"); expect(peerPrompt).not.toContain('"reply"'); expect(peerPrompt).toContain( - 'Use "send_message" with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.' + 'Use "mcp__loop-bridge-repo-123-1__send_message" with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.' + ); + expect(peerPrompt).not.toContain("mcp__loop-bridge-repo-123-1__ prefix"); + expect(peerPrompt).toContain('"mcp__loop-bridge-repo-123-1__bridge_status"'); + expect(peerPrompt).toContain( + '"mcp__loop-bridge-repo-123-1__receive_messages"' ); expect(peerPrompt).toContain( "If you are answering Codex, use the bridge tools instead of a human-facing reply." ); expect(primaryPrompt).not.toContain("mcp__loop-bridge-repo-123-1__ prefix"); - expect(peerPrompt).toContain("mcp__loop-bridge-repo-123-1__ prefix"); + expect(peerPrompt).not.toContain("mcp__loop-bridge-repo-123-1__ prefix"); }); test("runInTmux auto-confirms Claude startup prompts in paired mode", async () => {