feat: report SendMessage delegations as linked session messages (#1424)

In the subagents RFD form, a `SendMessage` call to a child now becomes a
message between the two sessions, as a launch's prompt already does
(#1405). Before, it was a tool call of its sender plus an unlinked
message in the child. Every delegation now has a parent-side message,
which a client can anchor a per-delegation card on, and both views name
each other with `senderMessageId`/`recipientMessageId`.

## Live

- The call is held until its final update. The SDK reports that the
message resumed a child before it reports the call's result.
- **Message reached an announced child:** the sender gets the outgoing
view and the child the incoming one (`linkedSessionMessages`). The
call's result isn't shown.
- **Message reached no announced child**, for example one sent to the
parent: the call is shown after all, with its final status
(`unexposedControlFallback`).
- **Call a permission request already showed:** it keeps its updates,
and its message goes only to the child.

## Replay

- A child's history replays right after its launch, so a call from a
parent to its child is always matched to its delivery first.
- The child's view then names the call, and the sender's view goes out
where the call sits.
- A call the replay reaches before its delivery stays a tool call,
unlinked. That can happen with a child's message to a sibling whose
history replays later.

`publishPrompt`'s `replacesLaunch` option is now `replacesCall`.

## Testing

- **Live:** the existing `SendMessage` tests in
`native-subagents.test.ts` and `acp-agent.test.ts` now expect both views
and check that the call and its completed result are hidden. A new test
checks that a call reaching no announced child is shown with its final
status.
- **Replay:** the existing test expects the linked views in place of
`root tool_call toolu_send`. A new test covers a call reached before its
delivery, and fails if delivery ignores that the call was already shown.
- `npm run check`, `npm run build` and `npm run test:run` (3301 tests)
pass locally.

Not tried against a real Claude Code run yet.
This commit is contained in:
Ben Brandt
2026-10-25 00:14:54 +02:00
committed by GitHub
parent 17a967b4da
commit 739dde3ae5
4 changed files with 249 additions and 38 deletions

View File

@@ -164,6 +164,15 @@ export class NativeSubagentRuntime {
* closes an unfinished call has no `toolName` to recognize it by.
*/
private readonly hiddenLaunches = new Map<string, { background: boolean }>();
/**
* The RFD form: the SendMessage calls that the client is not shown, until
* their final update, with their first report and the session that made
* them. A message that reaches an announced child replaces its call.
*/
private readonly hiddenSendMessages = new Map<
string,
{ initial: AcpSessionNotification; sender: string; delivered: boolean }
>();
/**
* The RFD form: the SubagentHandback calls of announced children that the
* client is not shown yet, with their first report, the report from their
@@ -223,8 +232,9 @@ export class NativeSubagentRuntime {
forcedSessionId?: string,
): Promise<AcpSessionNotification | null> {
const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
if (routed) this.rememberSendMessage(routed);
return routed;
if (!routed) return routed;
this.rememberSendMessage(routed);
return this.form === "rfd" ? this.routeRfdSendMessage(routed, forcedSessionId) : routed;
}
/**
@@ -1007,13 +1017,16 @@ export class NativeSubagentRuntime {
});
}
if (prompt && toolUseId) {
const sendMessage = this.hiddenSendMessages.get(toolUseId);
await publishPrompt(
child,
prompt,
toolUseId,
this.senderByToolUse.get(toolUseId),
sendMessage?.sender ?? this.senderByToolUse.get(toolUseId),
this.publish,
{ replacesCall: sendMessage !== undefined },
);
if (sendMessage) sendMessage.delivered = true;
}
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
}
@@ -1221,6 +1234,44 @@ export class NativeSubagentRuntime {
return unexposedControlFallback(handback.initial, notification, child.sessionId);
}
/**
* The RFD form: an update of a SendMessage call, which its message replaces
* once it reaches an announced child: the sender's outgoing view and the
* child's incoming one, see {@link linkedSessionMessages}. The SDK reports
* that delivery before the call's final update. A call whose message reached
* no announced child, such as one to the parent, is shown after all, with its
* final status. Any other update is returned as is.
*/
private routeRfdSendMessage(
notification: AcpSessionNotification,
forcedSessionId: string | undefined,
): AcpSessionNotification | null {
const { update } = notification;
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
return notification;
}
const toolCallId = update.toolCallId;
// A permission request already showed the call to the client, so it keeps
// getting its updates, and its message goes only to the child.
if (forcedSessionId) {
this.hiddenSendMessages.delete(toolCallId);
return notification;
}
let sendMessage = this.hiddenSendMessages.get(toolCallId);
if (!sendMessage) {
const claudeMeta = update._meta?.claudeCode as { toolName?: string } | undefined;
if (update.sessionUpdate !== "tool_call" || claudeMeta?.toolName !== "SendMessage") {
return notification;
}
sendMessage = { initial: notification, sender: notification.sessionId, delivered: false };
this.hiddenSendMessages.set(toolCallId, sendMessage);
}
if (!isFinalToolCallStatus(update.status)) return null;
this.hiddenSendMessages.delete(toolCallId);
if (sendMessage.delivered) return null;
return unexposedControlFallback(sendMessage.initial, notification, sendMessage.sender);
}
/** Whether `update` is of an Agent/Task call, by its tool name or, in the
* RFD form, by the id of a call the client is not shown. */
private isControlUpdate(
@@ -1283,6 +1334,7 @@ export class NativeSubagentRuntime {
this.taskFinishPromises.clear();
this.senderByToolUse.clear();
this.hiddenLaunches.clear();
this.hiddenSendMessages.clear();
this.handbacks.clear();
this.generationByTaskId.clear();
this.pending.clear();
@@ -1461,7 +1513,7 @@ export async function announceNativeSubagent(
child.parentToolUseId,
child.parentSessionId,
publish,
{ replacesLaunch: true },
{ replacesCall: true },
);
}
return;
@@ -1523,9 +1575,9 @@ export async function finishNativeSubagent(
* transcript is the same delegation and is not sent again. `sender` is the
* session that made that call, when known.
*
* A launch's prompt (`replacesLaunch`) also goes to the parent, see
* {@link linkedSessionMessages}. A SendMessage call stays a tool call of the
* sender, so its message has only the incoming view.
* When the message replaces its call (`replacesCall`), a launch's or a
* SendMessage call the client is not shown, it also goes to the sender, see
* {@link linkedSessionMessages}. Otherwise it has only the incoming view.
*/
async function publishPrompt(
child: NativeSubagent,
@@ -1533,13 +1585,13 @@ async function publishPrompt(
toolUseId: string | undefined,
sender: string | undefined,
publish: Publish,
{ replacesLaunch = false }: { replacesLaunch?: boolean } = {},
{ replacesCall = false }: { replacesCall?: boolean } = {},
): Promise<void> {
child.promptIds ??= new Set();
const messageId = toolUseId ?? `${child.sessionId}:prompt:${child.promptIds.size + 1}`;
if (child.promptIds.has(messageId)) return;
child.promptIds.add(messageId);
if (replacesLaunch && sender !== undefined && toolUseId !== undefined) {
if (replacesCall && sender !== undefined && toolUseId !== undefined) {
for (const view of linkedSessionMessages(toolUseId, sender, child.sessionId, prompt)) {
await publish(view);
}
@@ -1561,8 +1613,9 @@ async function publishPrompt(
* The RFD form: a message that a tool call sent, which the client is not
* shown, as two session messages: the sender's outgoing view, then the
* recipient's incoming one. That call is a launch's Agent/Task call, whose
* prompt goes from the parent to the child, or a child's SubagentHandback
* call, whose report goes from the child to the parent. Both views use the
* prompt goes from the parent to the child, a SendMessage call, whose message
* goes from its sender to a child, or a child's SubagentHandback call, whose
* report goes from the child to the parent. Both views use the
* call's tool use id, and each names the other with the draft RFD's
* `recipientMessageId` and `senderMessageId`.
*/
@@ -1832,7 +1885,8 @@ function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boole
* A tool call that the client was not shown, as one `tool_call` with its
* final status, shown after all: an Agent/Task call whose child was never
* exposed (AIR's draft shows only a failed one, the RFD form also a completed
* one), or a failed SubagentHandback call.
* one), a SendMessage call whose message reached no announced child, or a
* failed SubagentHandback call.
*/
function unexposedControlFallback(
initial: AcpSessionNotification | undefined,

View File

@@ -16,6 +16,12 @@
* Unlike live, the replay does not tell runs apart, so a child that handed
* back any report gets no reply from its launch's result.
*
* A SendMessage call whose message the replay delivered to a child before it
* reached the call is that message too: the sender's outgoing view, at the
* call, and the child's incoming one, in the child's history. A child's
* history follows its launch, so a call to a child from its parent is always
* delivered first. Any other SendMessage call stays a tool call.
*
* A launch without metadata has no known id, so it is not announced: the RFD
* forbids inventing one. Its Agent call stays an ordinary tool call of the
* parent, and a notice says that subagent history is missing.
@@ -64,7 +70,12 @@ type SendMessageCall = {
to: string;
message: string;
senderSessionId: string;
delivered: boolean;
/** The child the replay delivered the message to. */
recipientSessionId?: string;
/** The replay reached the call before the delivery, so it stays a tool call. */
shownAsToolCall: boolean;
/** The sender's view of the message went out, at the call. */
outgoingSent: boolean;
};
export class RfdSubagentReplay {
@@ -108,7 +119,8 @@ export class RfdSubagentReplay {
to: input.to,
message: input.message,
senderSessionId: ownerSessionId,
delivered: false,
shownAsToolCall: false,
outgoingSent: false,
});
}
}
@@ -194,10 +206,11 @@ export class RfdSubagentReplay {
/**
* Takes a replayed update of a tool call, from the transcript of `owner`
* when it is a child's. Returns whether the client is not shown it: an
* update of the launch of a child that the replay shows, or of a successful
* update of the launch of a child that the replay shows, of a SendMessage
* call whose message the replay already delivered, or of a successful
* SubagentHandback call of `owner`. A foreground launch's completed result
* goes out as the child's reply, and a hand-back as the child's message to
* its parent.
* goes out as the child's reply, a delivered SendMessage as the sender's
* outgoing message, and a hand-back as the child's message to its parent.
*/
async toolCallUpdate(
update: AcpSessionNotification["update"],
@@ -206,6 +219,24 @@ export class RfdSubagentReplay {
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
return false;
}
const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId);
if (sendMessage) {
if (sendMessage.recipientSessionId === undefined) {
sendMessage.shownAsToolCall = true;
return false;
}
if (!sendMessage.outgoingSent) {
sendMessage.outgoingSent = true;
const [outgoing] = linkedSessionMessages(
sendMessage.toolUseId,
sendMessage.senderSessionId,
sendMessage.recipientSessionId,
sendMessage.message,
);
if (outgoing) await this.publish(outgoing);
}
return true;
}
const handback = owner ? this.handbacks.get(update.toolCallId) : undefined;
if (owner && handback && !handback.failed) {
if (!handback.sent) {
@@ -355,6 +386,9 @@ export class RfdSubagentReplay {
* first undelivered SendMessage to the child whose message the delivered
* text contains. With that call, the message has the call's id and text and
* its sender, as live; without it, its transcript id and the delivered text.
* Unless the replay already showed the call as a tool call, the message
* replaces it, so this view names the sender's view, which goes out at the
* call (see {@link toolCallUpdate}).
*/
private async sendDelivery(
child: ReplayChild,
@@ -364,13 +398,15 @@ export class RfdSubagentReplay {
const names = [child.sessionId, child.record.name, child.input.name];
const call = this.sendMessages.find(
(candidate) =>
!candidate.delivered &&
candidate.recipientSessionId === undefined &&
names.includes(candidate.to) &&
text.includes(candidate.message.trim()),
);
if (call) {
call.delivered = true;
await this.sendMessage(child, call.toolUseId, call.message, call.senderSessionId);
call.recipientSessionId = child.sessionId;
await this.sendMessage(child, call.toolUseId, call.message, call.senderSessionId, {
linked: !call.shownAsToolCall,
});
} else {
await this.sendMessage(child, message.uuid, text, undefined);
}
@@ -381,6 +417,7 @@ export class RfdSubagentReplay {
messageId: string,
text: string,
sender: string | undefined,
{ linked = false }: { linked?: boolean } = {},
): Promise<void> {
await this.publish({
sessionId: child.sessionId,
@@ -389,6 +426,7 @@ export class RfdSubagentReplay {
messageId,
...(sender ? { senderSessionId: sender } : {}),
recipientSessionId: child.sessionId,
...(linked ? { senderMessageId: messageId } : {}),
content: [{ type: "text", text }],
},
});

View File

@@ -4058,11 +4058,13 @@ describe("subagent transcript replay", () => {
"def456 agent_message_chunk X.",
"abc123 session_message toolu_nested:reply from=def456 The spec says X.",
// The delivered message has the id, text, and sender of its SendMessage
// call, which stays a tool call of its sender.
"abc123 session_message toolu_send from=" + sessionId + " Check the tests too",
// call, which it replaces: the sender's view goes out at the call.
"abc123 session_message toolu_send from=" +
sessionId +
" senderMessageId=toolu_send Check the tests too",
"abc123 agent_message_chunk Tests pass.",
"root session_message toolu_agent:reply from=abc123 Found it.",
"root tool_call toolu_send",
`root session_message toolu_send from=${sessionId} recipientMessageId=toolu_send Check the tests too`,
"root agent_message_chunk Done.",
// Every end comes before the response.
'abc123 subagent_update def456 {"state":"idle","stopReason":"end_turn"}',
@@ -4100,6 +4102,55 @@ describe("subagent transcript replay", () => {
]);
});
it("keeps a SendMessage call that the replay reaches before its delivery as a tool call", async () => {
const launch = (id: string, name: string) =>
toolUse(id, "Agent", { name, subagent_type: "Explore", prompt: `Run ${name}` });
const meta = (name: string, launchId: string) => ({
agentType: "Explore",
name,
toolUseId: launchId,
requestShape: "foreground",
});
const trace = await replayRfd(
[
user("u1", "Go"),
assistant("a1", [launch("toolu_a", "alpha"), launch("toolu_b", "beta")]),
user("u2", [toolResult("toolu_a", "A."), toolResult("toolu_b", "B.")]),
],
{
// alpha's history, which replays first, messages beta.
aaa111: {
meta: meta("alpha", "toolu_a"),
records: [
user("a1", "Run alpha"),
assistant("a2", [
toolUse("toolu_sib", "SendMessage", { to: "bbb222", message: "Ping sibling" }),
]),
user("a3", [toolResult("toolu_sib", "Message sent.")]),
],
},
bbb222: {
meta: meta("beta", "toolu_b"),
records: [
user("b1", "Run beta"),
user(
"b2",
"The coordinator sent a message while you were working:\nPing sibling\n\nAddress this before completing your current task.",
{ isMeta: true, origin: { kind: "coordinator" } },
),
],
},
},
);
expect(trace.filter((line) => line.includes("toolu_sib"))).toEqual([
// The call came first, so it stays a tool call, and the message that
// arrives later names no other view.
"aaa111 tool_call toolu_sib",
"bbb222 session_message toolu_sib from=aaa111 Ping sibling",
]);
});
it("replays a child's hand-back as its message to the parent, instead of the launch's result", async () => {
const trace = await replayRfd(
[
@@ -7754,6 +7805,18 @@ describe("subagent permission attribution (issue #851)", () => {
state: { state: "running" },
},
},
// The SendMessage call's message replaces it, as a launch's does.
{
sessionId: "test-session",
update: {
sessionUpdate: "session_message",
messageId: "toolu_send",
senderSessionId: "test-session",
recipientSessionId: "agent-42",
recipientMessageId: "toolu_send",
content: [{ type: "text", text: "Also check Windows" }],
},
},
{
sessionId: "agent-42",
update: {
@@ -7761,6 +7824,7 @@ describe("subagent permission attribution (issue #851)", () => {
messageId: "toolu_send",
senderSessionId: "test-session",
recipientSessionId: "agent-42",
senderMessageId: "toolu_send",
content: [{ type: "text", text: "Also check Windows" }],
},
},
@@ -7905,6 +7969,7 @@ describe("subagent permission attribution (issue #851)", () => {
messageId: "toolu_send",
senderSessionId: "test-session",
recipientSessionId: "agent-7",
senderMessageId: "toolu_send",
content: [{ type: "text", text: "Say PING" }],
},
},
@@ -7924,6 +7989,17 @@ describe("subagent permission attribution (issue #851)", () => {
},
},
]);
// The root has the message instead of the completed SendMessage call.
const root = updates.flatMap(({ sessionId, update }) =>
sessionId === "test-session" &&
"toolCallId" in update &&
update.toolCallId === "toolu_send"
? [update.sessionUpdate]
: sessionId === "test-session" && update.sessionUpdate === "session_message"
? [`session_message ${update.messageId} to=${update.recipientSessionId}`]
: [],
);
expect(root).toEqual(["session_message toolu_send to=agent-7"]);
} finally {
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir;

View File

@@ -814,6 +814,19 @@ describe("NativeSubagentRuntime in the RFD form", () => {
},
},
}) as AcpSessionNotification;
const sendMessageResult = (
toolCallId: string,
status: "completed" | "failed",
): AcpSessionNotification =>
({
sessionId: "root",
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status,
_meta: { claudeCode: { toolName: "SendMessage" } },
},
}) as AcpSessionNotification;
/** A runtime whose child `worker-1` was launched by `launch-1` and runs. */
async function runningWorker(input: Record<string, unknown> = {}) {
@@ -1300,10 +1313,15 @@ describe("NativeSubagentRuntime in the RFD form", () => {
await expect(runtime.route(output("launch-1"), async () => {})).resolves.toMatchObject({
sessionId: "worker-1",
});
await runtime.route(sendMessage("send-1"), async () => {});
// The SendMessage call is not shown: its message replaces it.
await expect(runtime.route(sendMessage("send-1"), async () => {})).resolves.toBeNull();
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
// A repeated resume signal of the running child changes nothing.
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
// The SDK reports the delivery before the call's result, which stays hidden.
await expect(
runtime.route(sendMessageResult("send-1", "completed"), async () => {}),
).resolves.toBeNull();
await runtime.finishTask("worker-1", "completed", async () => {});
expect(states(published)).toEqual([
@@ -1312,21 +1330,19 @@ describe("NativeSubagentRuntime in the RFD form", () => {
{ state: "running" },
{ state: "idle", stopReason: "end_turn" },
]);
// The SendMessage call stays a tool call of its sender, so its message has
// only the child's view, with no reference.
// As a launch's prompt, the message goes to both, each view naming the other.
const message = {
sessionUpdate: "session_message",
messageId: "send-1",
senderSessionId: "root",
recipientSessionId: "worker-1",
content: [{ type: "text", text: "Check the tests too" }],
};
expect(published.filter(({ update }) => update.sessionUpdate === "session_message")).toEqual([
expect.objectContaining({ sessionId: "root" }),
expect.objectContaining({ sessionId: "worker-1" }),
{
sessionId: "worker-1",
update: {
sessionUpdate: "session_message",
messageId: "send-1",
senderSessionId: "root",
recipientSessionId: "worker-1",
content: [{ type: "text", text: "Check the tests too" }],
},
},
{ sessionId: "root", update: { ...message, recipientMessageId: "send-1" } },
{ sessionId: "worker-1", update: { ...message, senderMessageId: "send-1" } },
]);
expect(
published.some(({ update }) => "sessionId" in update && update.sessionId !== "worker-1"),
@@ -1371,7 +1387,22 @@ describe("NativeSubagentRuntime in the RFD form", () => {
.map(
({ sessionId, update }) => `${sessionId} ${(update as { messageId: string }).messageId}`,
),
).toEqual(["root launch-1", "worker-1 launch-1", "worker-1 send-1"]);
).toEqual(["root launch-1", "worker-1 launch-1", "root send-1", "worker-1 send-1"]);
});
it("shows a SendMessage call whose message reached no announced child", async () => {
const { runtime, published } = await runningWorker();
published.length = 0;
// A message to the parent, or to an agent this client was never shown,
// resumes no announced child.
await expect(runtime.route(sendMessage("send-2"), async () => {})).resolves.toBeNull();
await expect(
runtime.route(sendMessageResult("send-2", "completed"), async () => {}),
).resolves.toMatchObject({
sessionId: "root",
update: { sessionUpdate: "tool_call", toolCallId: "send-2", status: "completed" },
});
expect(published).toEqual([]);
});
it("ignores a SendMessage result that arrives after the run it started ended", async () => {
@@ -1546,6 +1577,17 @@ describe("NativeSubagentRuntime in the RFD form", () => {
state: { state: "running" },
},
},
{
sessionId: "root",
update: {
sessionUpdate: "session_message",
messageId: "send-1",
senderSessionId: "root",
recipientSessionId: "echoer-1",
recipientMessageId: "send-1",
content: [{ type: "text", text: "Say PING" }],
},
},
{
sessionId: "echoer-1",
update: {
@@ -1553,6 +1595,7 @@ describe("NativeSubagentRuntime in the RFD form", () => {
messageId: "send-1",
senderSessionId: "root",
recipientSessionId: "echoer-1",
senderMessageId: "send-1",
content: [{ type: "text", text: "Say PING" }],
},
},
@@ -1630,7 +1673,7 @@ describe("NativeSubagentRuntime in the RFD form", () => {
expect(
published.map(({ sessionId, update }) => `${sessionId} ${update.sessionUpdate}`),
).toEqual(["root subagent_update", "echoer-1 session_message"]);
).toEqual(["root subagent_update", "root session_message", "echoer-1 session_message"]);
});
it("keeps an agent that the history cannot place, or not read, unexposed", async () => {