mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user