fix: prefer requesterOrigin over stale session entry in subagent announce routing (#4957)

* fix: prefer requesterOrigin over stale session entry in subagent announce routing

When a subagent finishes and announces results back, resolveAnnounceOrigin
merged the session entry (primary) with requesterOrigin (fallback). If the
session store had a stale lastChannel (e.g. whatsapp) from a previous
interaction but the user was now on a different channel (e.g. bluebubbles),
the announce would route to the wrong channel.

Swap the merge order so requesterOrigin (captured at spawn time, reflecting
the actual current channel) takes priority, with the session entry as
fallback for any missing fields.

Error before fix:
  Delivery failed (whatsapp to bluebubbles:chat_guid:...): Unknown channel: whatsapp

Adds regression test for the stale-channel scenario.

* fix: match test to exact failure scenario and improve reliability (#4957) (thanks @tyler6204)

- Remove lastTo from stale session store to match the exact mismatch scenario described in the PR
- Replace 5ms setTimeout sleeps with expect.poll for better test reliability
- Prevents flakiness on slower CI machines
This commit is contained in:
Tyler Yust
2026-01-30 15:52:19 -08:00
committed by GitHub
parent 6a978aa1bc
commit 57248a7ca1
2 changed files with 44 additions and 3 deletions

View File

@@ -186,7 +186,7 @@ describe("subagent announce formatting", () => {
});
expect(didAnnounce).toBe(true);
await new Promise((r) => setTimeout(r, 5));
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.channel).toBe("whatsapp");
@@ -299,6 +299,44 @@ describe("subagent announce formatting", () => {
expect(call?.params?.accountId).toBe("acct-987");
});
it("prefers requesterOrigin channel over stale session lastChannel in queued announce", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
// Session store has stale whatsapp channel, but the requesterOrigin says bluebubbles.
sessionStore = {
"agent:main:main": {
sessionId: "session-stale",
lastChannel: "whatsapp",
queueMode: "collect",
queueDebounceMs: 0,
},
};
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-stale-channel",
requesterSessionKey: "main",
requesterOrigin: { channel: "bluebubbles", to: "bluebubbles:chat_guid:123" },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
// The channel should match requesterOrigin, NOT the stale session entry.
expect(call?.params?.channel).toBe("bluebubbles");
expect(call?.params?.to).toBe("bluebubbles:chat_guid:123");
});
it("splits collect-mode announces when accountId differs", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
@@ -343,7 +381,7 @@ describe("subagent announce formatting", () => {
outcome: { status: "ok" },
});
await new Promise((r) => setTimeout(r, 5));
await expect.poll(() => agentSpy.mock.calls.length).toBe(2);
const accountIds = agentSpy.mock.calls.map(
(call) => (call[0] as { params?: Record<string, unknown> }).params?.accountId,

View File

@@ -93,7 +93,10 @@ function resolveAnnounceOrigin(
entry?: DeliveryContextSource,
requesterOrigin?: DeliveryContext,
): DeliveryContext | undefined {
return mergeDeliveryContext(deliveryContextFromSession(entry), requesterOrigin);
// requesterOrigin (captured at spawn time) reflects the channel the user is
// actually on and must take priority over the session entry, which may carry
// stale lastChannel / lastTo values from a previous channel interaction.
return mergeDeliveryContext(requesterOrigin, deliveryContextFromSession(entry));
}
async function sendAnnounce(item: AnnounceQueueItem) {