You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The send_message server tool lets an agent send a message to another session or chat. It's supposed to refuse when an agent tries to message the chat it is already running in — otherwise a chat can kick off new work on itself, again and again.
That safety check works for Copilot. For Codex and Claude it silently does nothing, so the message goes through and a new turn starts in the same chat.
The E2E test for this is currently skipped for both providers via the supportsSelfSendRejection gate in serverToolsSuite.ts:79, and it's written up in KNOWN_ISSUES.md twice — once for Claude (line 390) and once for Codex (line 427).
The left side is always a chat URI (ahp-chat://default/<base64 of the session URI>), built by buildDefaultChatUri. The right side is whatever the provider handed us as "the channel I'm running on" — and the three providers hand us different kinds of thing.
flowchart TD
A["send_message(session: 'x')"] --> B["target chat<br/>= ahp-chat://default/base64(x)"]
B --> C{"is the target chat the same string<br/>as the channel I'm running on?"}
C -->|"Copilot passes<br/>ahp-chat://default/base64(x)<br/>→ same string"| D["Rejected ✅"]
C -->|"Codex / Claude pass<br/>codex:/x or claude:/x<br/>→ different string"| E["Sent. The chat messages itself ❌"]
claude/claudeServerToolMcpServer.ts:62, fed _storageUri from claude/claudeAgentSession.ts:768 (_storageUri is defined at claudeAgentSession.ts:133-135 and collapses the default chat down to the session URI)
claude:/<id> — a session URI
The proof: delete_session gets this right
delete_session has the same kind of "don't target yourself" check and it works on all three providers. The only difference is that it cleans up the channel first, and send_message doesn't.
flowchart LR
CH["channel from provider<br/>(chat URI or session URI —<br/>depends on the provider)"]
CH --> D["delete_session<br/>dispatch at line 1104"]
CH --> S["send_message<br/>dispatch at line 1097"]
D --> DN["currentSessionUri()<br/>lines 175-178<br/>turns a chat URI into a session URI"]
DN --> DG["guard at line 974<br/>compares like with like<br/>works everywhere ✅"]
S --> SG["no cleanup at all<br/>guard at line 743<br/>only works for Copilot ❌"]
Loading
Side note on why nobody caught this: the unit test at test/node/sessionServerTools.test.ts:441 hardcodes buildDefaultChatUri('copilot:/s1') as the channel, so it only ever exercises the Copilot shape.
Which way should we normalize?
Careful here — the obvious move (reuse currentSessionUri()) is the wrong one. That squashes a chat down to its session, which would make send_message refuse to message any chat in the current session, not just the one it's running in.
flowchart TD
CH["channel: claude:/x"]
CH --> W["❌ currentSessionUri()<br/>→ claude:/x (a session)"]
CH --> R["✅ currentChatUri() (new)<br/>→ ahp-chat://default/base64(x)"]
W --> WB["Too strict: also blocks messaging a<br/>sibling chat in the same session"]
R --> RB["Just right: blocks only the exact<br/>chat that invoked the tool"]
Loading
Four things say the rule is meant to be per chat, not per session:
The code comment: "Refuses to target currentChannel (the chat channel the tool runs on)" — sessionServerTools.ts:737
The error message users see: "refusing to send a message to the current chat" — line 744
The test name and what it matches on: send_message refuses to target the invoking chat, /current chat/i — serverToolsSuite.ts:743,750
It would break a real workflow: create_chat defaults to the current session (lines 674-676) and hands back a link whose whole point is to be passed to send_message (see the tool description on line 154). Squashing to the session would reject an agent messaging the sibling chat it just made.
Where to fix it: shared code, not the providers
Two options were considered:
(A) Normalize inside the guard. One shared change, fixes all three providers at once and any future provider too.
(B) Make Codex and Claude pass a chat URI like Copilot does. Riskier, touches live session wiring in two agents, and doesn't protect the next guard someone writes. For Claude specifically, _storageUri is deliberately the session URI for the default chat because it keys per-chat storage — changing what reaches buildServerToolMcpServer means introducing a second URI concept at that seam.
Recommend (A). It's smaller and safer.
Worth noting Codex is single-chat, so mapping its session URI to "the default chat" is unambiguous — chats.createChat throws 'Codex agent does not support multiple chats' (codexAgent.ts:2804-2806). Claude and Copilot already pass their real peer-chat URI when they're in a peer chat, so for them the normalization is a no-op.
Proposed helper, mirroring the existing currentSessionUri and using only helpers already imported in that file:
/** Resolves the chat channel URI for the channel a tool call runs on. */exportfunctioncurrentChatUri(toolCallChannel: ProtocolURI): URI{constcanonical=URI.parse(toolCallChannel).toString();returnURI.parse(parseChatUri(toolCallChannel) ? canonical : buildDefaultChatUri(canonical));}
Then compare against currentChatUri(currentChannel) at line 743. Running both sides through URI.parse(...).toString() first keeps the strings byte-identical before base64 encoding, matching what getSendMessageArgs already does with the session URI from listSessions.
Anything else with the same bug?
No — I checked every .toString() === in the file and every use of the dispatch channel:
The delete_session guard (line 974) and serializeCurrentSession (line 938) are handed already-normalized input from lines 1104 / 1076. Fine.
create_session / create_chat do pass the raw channel to getCreationDefaults (lines 600, 687 → agentService.ts:860), but that lands on AgentHostStateManager.getSessionState, which explicitly accepts either form (agentHostStateManager.ts:328-342). Fine.
send_message is the only one.
What this unblocks
supportsSelfSendRejection (serverToolsSuite.ts:79) can become unconditional — one test, for both Codex and Claude. The rest of that test's assertions follow for free: the throw happens before accessor.startPrompt, so no extra turn is ever started, and both providers already report the failure correctly (Codex wraps the throw via _toolFailure at codexAgent.ts:1783-1785; Claude's isError: true from claudeServerToolMcpServer.ts:66 maps to success: !isError at claudeMapSessionEvents.ts:386).
Not unblocked: supportsCrossSessionSend (serverToolsSuite.ts:75). Codex's send_message starts a turn in another session fails earlier with Authorization header is badly formatted — different root cause, leave that gate alone.
KNOWN_ISSUES.md: delete the Claude section (lines 390-401) and the Codex section (lines 427-438), and narrow the two repro commands at line 408 (drop |send_message refuses, and "all three Claude tests" becomes two) and line 445 (send_message becomes send_message starts a turn).
One practical catch: E2E replay is strict — an unrecorded request is a hard failure — and e2e/captures/ only has copilotcli-server-tool-send-message-refuses-to-target-the-invoking-chat.yaml. Ungating means recording Claude and Codex fixtures against real CAPI (AGENT_HOST_REPLAY_RECORD=1, needs a token).
Plan
Add currentChatUri() next to currentSessionUri() (sessionServerTools.ts:178) and use it in the guard at line 743.
Document on IAgentServerToolHost.executeTool (shared/agentServerToolHost.ts:137) that the channel can be either a chat URI or a bare session URI, and that guards must normalize with currentSessionUri / currentChatUri. This is the bit that stops the same bug coming back.
Extend the unit test at test/node/sessionServerTools.test.ts:435 to run the guard with all three channel shapes — chat URI (Copilot), bare session URI (Codex/Claude default chat), and a peer-chat URI — asserting the last one still allows messaging a sibling chat in the same session. This catches it without needing recorded fixtures.
Record Claude + Codex captures, flip supportsSelfSendRejection to true, update KNOWN_ISSUES.md.
Steps 1-3 are fully verifiable offline. Step 4 needs a token.
What's broken
The
send_messageserver tool lets an agent send a message to another session or chat. It's supposed to refuse when an agent tries to message the chat it is already running in — otherwise a chat can kick off new work on itself, again and again.That safety check works for Copilot. For Codex and Claude it silently does nothing, so the message goes through and a new turn starts in the same chat.
The E2E test for this is currently skipped for both providers via the
supportsSelfSendRejectiongate inserverToolsSuite.ts:79, and it's written up inKNOWN_ISSUES.mdtwice — once for Claude (line 390) and once for Codex (line 427).Why it happens
The check is a plain string comparison at
sessionServerTools.ts:743:The left side is always a chat URI (
ahp-chat://default/<base64 of the session URI>), built bybuildDefaultChatUri. The right side is whatever the provider handed us as "the channel I'm running on" — and the three providers hand us different kinds of thing.flowchart TD A["send_message(session: 'x')"] --> B["target chat<br/>= ahp-chat://default/base64(x)"] B --> C{"is the target chat the same string<br/>as the channel I'm running on?"} C -->|"Copilot passes<br/>ahp-chat://default/base64(x)<br/>→ same string"| D["Rejected ✅"] C -->|"Codex / Claude pass<br/>codex:/x or claude:/x<br/>→ different string"| E["Sent. The chat messages itself ❌"]Where each provider gets it from:
copilot/copilotAgentSession.ts:1692→_chatChannelUriahp-chat://default/<b64>— a chat URIcodex/codexAgent.ts:1781→session.sessionUricodex:/<id>— a session URIclaude/claudeServerToolMcpServer.ts:62, fed_storageUrifromclaude/claudeAgentSession.ts:768(_storageUriis defined atclaudeAgentSession.ts:133-135and collapses the default chat down to the session URI)claude:/<id>— a session URIThe proof:
delete_sessiongets this rightdelete_sessionhas the same kind of "don't target yourself" check and it works on all three providers. The only difference is that it cleans up the channel first, andsend_messagedoesn't.flowchart LR CH["channel from provider<br/>(chat URI or session URI —<br/>depends on the provider)"] CH --> D["delete_session<br/>dispatch at line 1104"] CH --> S["send_message<br/>dispatch at line 1097"] D --> DN["currentSessionUri()<br/>lines 175-178<br/>turns a chat URI into a session URI"] DN --> DG["guard at line 974<br/>compares like with like<br/>works everywhere ✅"] S --> SG["no cleanup at all<br/>guard at line 743<br/>only works for Copilot ❌"]Side note on why nobody caught this: the unit test at
test/node/sessionServerTools.test.ts:441hardcodesbuildDefaultChatUri('copilot:/s1')as the channel, so it only ever exercises the Copilot shape.Which way should we normalize?
Careful here — the obvious move (reuse
currentSessionUri()) is the wrong one. That squashes a chat down to its session, which would makesend_messagerefuse to message any chat in the current session, not just the one it's running in.flowchart TD CH["channel: claude:/x"] CH --> W["❌ currentSessionUri()<br/>→ claude:/x (a session)"] CH --> R["✅ currentChatUri() (new)<br/>→ ahp-chat://default/base64(x)"] W --> WB["Too strict: also blocks messaging a<br/>sibling chat in the same session"] R --> RB["Just right: blocks only the exact<br/>chat that invoked the tool"]Four things say the rule is meant to be per chat, not per session:
currentChannel(the chat channel the tool runs on)" —sessionServerTools.ts:737send_message refuses to target the invoking chat,/current chat/i—serverToolsSuite.ts:743,750create_chatdefaults to the current session (lines 674-676) and hands back a link whose whole point is to be passed tosend_message(see the tool description on line 154). Squashing to the session would reject an agent messaging the sibling chat it just made.Where to fix it: shared code, not the providers
Two options were considered:
_storageUriis deliberately the session URI for the default chat because it keys per-chat storage — changing what reachesbuildServerToolMcpServermeans introducing a second URI concept at that seam.Recommend (A). It's smaller and safer.
Worth noting Codex is single-chat, so mapping its session URI to "the default chat" is unambiguous —
chats.createChatthrows'Codex agent does not support multiple chats'(codexAgent.ts:2804-2806). Claude and Copilot already pass their real peer-chat URI when they're in a peer chat, so for them the normalization is a no-op.Proposed helper, mirroring the existing
currentSessionUriand using only helpers already imported in that file:Then compare against
currentChatUri(currentChannel)at line 743. Running both sides throughURI.parse(...).toString()first keeps the strings byte-identical before base64 encoding, matching whatgetSendMessageArgsalready does with the session URI fromlistSessions.Anything else with the same bug?
No — I checked every
.toString() ===in the file and every use of the dispatch channel:delete_sessionguard (line 974) andserializeCurrentSession(line 938) are handed already-normalized input from lines 1104 / 1076. Fine.create_session/create_chatrecursion guards (lines 593, 685) callcurrentSessionUrithemselves. Fine.create_session/create_chatdo pass the raw channel togetCreationDefaults(lines 600, 687 →agentService.ts:860), but that lands onAgentHostStateManager.getSessionState, which explicitly accepts either form (agentHostStateManager.ts:328-342). Fine.send_messageis the only one.What this unblocks
supportsSelfSendRejection(serverToolsSuite.ts:79) can become unconditional — one test, for both Codex and Claude. The rest of that test's assertions follow for free: the throw happens beforeaccessor.startPrompt, so no extra turn is ever started, and both providers already report the failure correctly (Codex wraps the throw via_toolFailureatcodexAgent.ts:1783-1785; Claude'sisError: truefromclaudeServerToolMcpServer.ts:66maps tosuccess: !isErroratclaudeMapSessionEvents.ts:386).supportsCrossSessionSend(serverToolsSuite.ts:75). Codex'ssend_message starts a turn in another sessionfails earlier withAuthorization header is badly formatted— different root cause, leave that gate alone.KNOWN_ISSUES.md: delete the Claude section (lines 390-401) and the Codex section (lines 427-438), and narrow the two repro commands at line 408 (drop|send_message refuses, and "all three Claude tests" becomes two) and line 445 (send_messagebecomessend_message starts a turn).One practical catch: E2E replay is strict — an unrecorded request is a hard failure — and
e2e/captures/only hascopilotcli-server-tool-send-message-refuses-to-target-the-invoking-chat.yaml. Ungating means recording Claude and Codex fixtures against real CAPI (AGENT_HOST_REPLAY_RECORD=1, needs a token).Plan
currentChatUri()next tocurrentSessionUri()(sessionServerTools.ts:178) and use it in the guard at line 743.IAgentServerToolHost.executeTool(shared/agentServerToolHost.ts:137) that the channel can be either a chat URI or a bare session URI, and that guards must normalize withcurrentSessionUri/currentChatUri. This is the bit that stops the same bug coming back.test/node/sessionServerTools.test.ts:435to run the guard with all three channel shapes — chat URI (Copilot), bare session URI (Codex/Claude default chat), and a peer-chat URI — asserting the last one still allows messaging a sibling chat in the same session. This catches it without needing recorded fixtures.supportsSelfSendRejectiontotrue, updateKNOWN_ISSUES.md.Steps 1-3 are fully verifiable offline. Step 4 needs a token.
All line numbers are against
main@28a37ffe0f3.