|
1 | 1 | // Subagent announce delivery tests cover the last-mile routing used when child |
2 | 2 | // runs report progress or completion back to the requester session. |
3 | 3 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 4 | +import { |
| 5 | + useTempSessionsFixture, |
| 6 | + writeSessionStoreForTest, |
| 7 | +} from "../config/sessions/test-helpers.js"; |
4 | 8 | import { OutboundDeliveryError } from "../infra/outbound/deliver-types.js"; |
5 | 9 | import { |
6 | 10 | testing as sessionBindingServiceTesting, |
@@ -4115,6 +4119,57 @@ describe("deliverSubagentAnnouncement completion delivery", () => { |
4115 | 4119 | expect(sendMessage).not.toHaveBeenCalled(); |
4116 | 4120 | }); |
4117 | 4121 |
|
| 4122 | + it("directly delivers DM media completions when the requester handoff fails", async () => { |
| 4123 | + // Companion to the Slack-channel case above. For a *direct-message* target a |
| 4124 | + // failed requester-agent handoff must NOT drop the user-requested media — |
| 4125 | + // regression test for background image_generate completions that were |
| 4126 | + // silently lost when the requester session could not be woken |
| 4127 | + // (no_active_run on the MCP-bridged CLI runtime), leaving the user with |
| 4128 | + // nothing until they pinged again. |
| 4129 | + const callGateway = vi.fn(async () => { |
| 4130 | + throw new Error("requester handoff exploded after dispatch"); |
| 4131 | + }) as unknown as typeof runtimeCallGateway; |
| 4132 | + const sendMessage = createSendMessageMock(); |
| 4133 | + |
| 4134 | + const result = await deliverDiscordDirectMessageCompletion({ |
| 4135 | + callGateway, |
| 4136 | + sendMessage, |
| 4137 | + sourceTool: "image_generate", |
| 4138 | + internalEvents: [ |
| 4139 | + { |
| 4140 | + type: "task_completion", |
| 4141 | + source: "image_generation", |
| 4142 | + childSessionKey: "image_generate:task-dm-error", |
| 4143 | + childSessionId: "task-dm-error", |
| 4144 | + announceType: "image generation task", |
| 4145 | + taskLabel: "errored handoff dm image", |
| 4146 | + status: "ok", |
| 4147 | + statusLabel: "completed successfully", |
| 4148 | + result: "Generated 1 image.\nMEDIA:/tmp/generated-dm-error.png", |
| 4149 | + mediaUrls: ["/tmp/generated-dm-error.png"], |
| 4150 | + replyInstruction: |
| 4151 | + "Tell the user the image is ready and send it through the message tool.", |
| 4152 | + }, |
| 4153 | + ], |
| 4154 | + }); |
| 4155 | + |
| 4156 | + expectRecordFields(result, { |
| 4157 | + delivered: true, |
| 4158 | + path: "direct", |
| 4159 | + }); |
| 4160 | + expect(callGateway).toHaveBeenCalledTimes(1); |
| 4161 | + expect(sendMessage).toHaveBeenCalledWith( |
| 4162 | + expect.objectContaining({ |
| 4163 | + channel: "discord", |
| 4164 | + accountId: "acct-1", |
| 4165 | + to: "dm:U123", |
| 4166 | + content: "The generated image is ready.", |
| 4167 | + mediaUrls: ["/tmp/generated-dm-error.png"], |
| 4168 | + idempotencyKey: "announce-dm-fallback-empty:generated-media-direct", |
| 4169 | + }), |
| 4170 | + ); |
| 4171 | + }); |
| 4172 | + |
4118 | 4173 | it("runs inactive isolated cron media completions through the requester agent first", async () => { |
4119 | 4174 | const callGateway = createGatewayMock({ |
4120 | 4175 | result: { |
@@ -4811,3 +4866,95 @@ describe("deliverSubagentAnnouncement completion delivery", () => { |
4811 | 4866 | }); |
4812 | 4867 | }); |
4813 | 4868 | }); |
| 4869 | + |
| 4870 | +describe("deliverSubagentAnnouncement origin backfill from session store", () => { |
| 4871 | + // Scoped temp session store so we can seed the requester session's persisted |
| 4872 | + // delivery context (lastChannel/lastTo) without touching the real store. |
| 4873 | + const sessionsFixture = useTempSessionsFixture("openclaw-announce-backfill-"); |
| 4874 | + |
| 4875 | + it("backfills an empty completion origin from the requester session's stored delivery context", async () => { |
| 4876 | + // Regression test for the real background-task drop: the claude-cli / |
| 4877 | + // MCP-bridged runtime captures an EMPTY (or internal "webchat") completion |
| 4878 | + // origin, so the captured origin carries no deliverable channel. Without |
| 4879 | + // backfilling the requester session's persisted lastChannel/lastTo, the |
| 4880 | + // media completion has no external target and is silently dropped (the user |
| 4881 | + // gets nothing until they ping again). |
| 4882 | + // |
| 4883 | + // This test supplies NO deliverable origin in any param — completion/direct/ |
| 4884 | + // session origins are all omitted. Delivery can therefore only succeed if |
| 4885 | + // deliveryContextFromSession(entry) rescues the Telegram target from the |
| 4886 | + // session store. Without the backfill the announce handoff returns no |
| 4887 | + // visible output and the result is a `visible_reply_missing` drop with no |
| 4888 | + // sendMessage call. |
| 4889 | + const requesterSessionKey = "agent:main:telegram:75597985"; |
| 4890 | + writeSessionStoreForTest(sessionsFixture.storePath(), { |
| 4891 | + [requesterSessionKey]: { |
| 4892 | + sessionId: "requester-session-telegram", |
| 4893 | + lastChannel: "telegram", |
| 4894 | + lastTo: "75597985", |
| 4895 | + lastAccountId: "bot-1", |
| 4896 | + }, |
| 4897 | + }); |
| 4898 | + |
| 4899 | + // Handoff succeeds (does not throw) but produces no visible output — the |
| 4900 | + // `private-final` case where the woken turn never calls the message tool. |
| 4901 | + const callGateway = createGatewayMock({ result: { payloads: [] } }); |
| 4902 | + const sendMessage = createSendMessageMock(); |
| 4903 | + testing.setDepsForTest({ |
| 4904 | + callGateway, |
| 4905 | + getRequesterSessionActivity: () => ({ |
| 4906 | + sessionId: "requester-session-telegram", |
| 4907 | + isActive: false, |
| 4908 | + }), |
| 4909 | + getRuntimeConfig: () => ({ session: { store: sessionsFixture.storePath() } }) as never, |
| 4910 | + sendMessage, |
| 4911 | + }); |
| 4912 | + |
| 4913 | + const result = await deliverSubagentAnnouncement({ |
| 4914 | + requesterSessionKey, |
| 4915 | + targetRequesterSessionKey: requesterSessionKey, |
| 4916 | + triggerMessage: "child done", |
| 4917 | + steerMessage: "child done", |
| 4918 | + // Deliberately no requesterOrigin / requesterSessionOrigin / |
| 4919 | + // completionDirectOrigin / directOrigin: the session-store backfill is the |
| 4920 | + // only possible source of a deliverable Telegram target. |
| 4921 | + requesterIsSubagent: false, |
| 4922 | + expectsCompletionMessage: true, |
| 4923 | + bestEffortDeliver: true, |
| 4924 | + directIdempotencyKey: "announce-telegram-backfill", |
| 4925 | + sourceTool: "image_generate", |
| 4926 | + internalEvents: [ |
| 4927 | + { |
| 4928 | + type: "task_completion", |
| 4929 | + source: "image_generation", |
| 4930 | + childSessionKey: "image_generate:task-backfill", |
| 4931 | + childSessionId: "task-backfill", |
| 4932 | + announceType: "image generation task", |
| 4933 | + taskLabel: "backfill origin image", |
| 4934 | + status: "ok", |
| 4935 | + statusLabel: "completed successfully", |
| 4936 | + result: "Generated 1 image.\nMEDIA:/tmp/generated-backfill.png", |
| 4937 | + mediaUrls: ["/tmp/generated-backfill.png"], |
| 4938 | + replyInstruction: |
| 4939 | + "Tell the user the image is ready and send it through the message tool.", |
| 4940 | + }, |
| 4941 | + ], |
| 4942 | + }); |
| 4943 | + |
| 4944 | + expectRecordFields(result, { |
| 4945 | + delivered: true, |
| 4946 | + path: "direct", |
| 4947 | + }); |
| 4948 | + // The Telegram target (channel/to/accountId) was resolved purely from the |
| 4949 | + // backfilled session entry — that is the behaviour under test. |
| 4950 | + expect(sendMessage).toHaveBeenCalledWith( |
| 4951 | + expect.objectContaining({ |
| 4952 | + channel: "telegram", |
| 4953 | + accountId: "bot-1", |
| 4954 | + to: "75597985", |
| 4955 | + mediaUrls: ["/tmp/generated-backfill.png"], |
| 4956 | + idempotencyKey: "announce-telegram-backfill:generated-media-direct", |
| 4957 | + }), |
| 4958 | + ); |
| 4959 | + }); |
| 4960 | +}); |
0 commit comments