Compare commits

...

2 Commits

Author SHA1 Message Date
Tyler Yust
6e5f6a6c5a fix: stabilize subagent announce regression tests (#4957) 2026-01-30 14:50:02 -08:00
Tyler Yust
10909e2950 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.
2026-01-30 14:43:20 -08:00
2 changed files with 64 additions and 2 deletions

View File

@@ -171,6 +171,15 @@ describe("subagent announce formatting", () => {
},
};
let resolveCalled: (() => void) | undefined;
const called = new Promise<void>((resolve) => {
resolveCalled = resolve;
});
agentSpy.mockImplementationOnce(async (..._args) => {
resolveCalled?.();
return { runId: "run-main", status: "ok" } as any;
});
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-999",
@@ -186,7 +195,7 @@ describe("subagent announce formatting", () => {
});
expect(didAnnounce).toBe(true);
await new Promise((r) => setTimeout(r, 5));
await called;
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.channel).toBe("whatsapp");
@@ -299,6 +308,56 @@ 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",
lastTo: "+1555",
queueMode: "collect",
queueDebounceMs: 0,
},
};
let resolveCalled: (() => void) | undefined;
const called = new Promise<void>((resolve) => {
resolveCalled = resolve;
});
agentSpy.mockImplementationOnce(async (..._args) => {
resolveCalled?.();
return { runId: "run-main", status: "ok" } as any;
});
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 called;
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
// This would fail on main prior to the fix: mergeDeliveryContext(session, requesterOrigin) would
// keep the stale session channel ("whatsapp") while taking the requesterOrigin `to`, creating a mismatch.
expect(call?.params?.channel).not.toBe("whatsapp");
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);

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) {