




















@@ -1,6 +1,14 @@
11import { afterEach, describe, expect, it, vi } from "vitest";
2-import { __testing, deliverSubagentAnnouncement } from "./subagent-announce-delivery.js";
3-import { callGateway as runtimeCallGateway } from "./subagent-announce-delivery.runtime.js";
2+import type { AgentInternalEvent } from "./internal-events.js";
3+import {
4+__testing,
5+deliverSubagentAnnouncement,
6+extractThreadCompletionFallbackText,
7+} from "./subagent-announce-delivery.js";
8+import {
9+callGateway as runtimeCallGateway,
10+sendMessage as runtimeSendMessage,
11+} from "./subagent-announce-delivery.runtime.js";
412import { resolveAnnounceOrigin } from "./subagent-announce-origin.js";
513614afterEach(() => {
@@ -14,8 +22,18 @@ const slackThreadOrigin = {
1422threadId: "171.222",
1523} as const;
162417-function createGatewayMock() {
18-return vi.fn(async () => ({}) as Record<string, unknown>) as unknown as typeof runtimeCallGateway;
25+function createGatewayMock(response: Record<string, unknown> = {}) {
26+return vi.fn(async () => response) as unknown as typeof runtimeCallGateway;
27+}
28+29+function createSendMessageMock() {
30+return vi.fn(async () => ({
31+channel: "slack",
32+to: "channel:C123",
33+via: "direct" as const,
34+mediaUrl: null,
35+result: { messageId: "msg-1" },
36+})) as unknown as typeof runtimeSendMessage;
1937}
20382139async function deliverSlackThreadAnnouncement(params: {
@@ -25,6 +43,8 @@ async function deliverSlackThreadAnnouncement(params: {
2543expectsCompletionMessage: boolean;
2644directIdempotencyKey: string;
2745queueEmbeddedPiMessage?: (sessionId: string, message: string) => boolean;
46+sendMessage?: typeof runtimeSendMessage;
47+internalEvents?: AgentInternalEvent[];
2848}) {
2949__testing.setDepsForTest({
3050callGateway: params.callGateway,
@@ -36,6 +56,7 @@ async function deliverSlackThreadAnnouncement(params: {
3656 ...(params.queueEmbeddedPiMessage
3757 ? { queueEmbeddedPiMessage: params.queueEmbeddedPiMessage }
3858 : {}),
59+ ...(params.sendMessage ? { sendMessage: params.sendMessage } : {}),
3960});
40614162return deliverSubagentAnnouncement({
@@ -51,6 +72,7 @@ async function deliverSlackThreadAnnouncement(params: {
5172expectsCompletionMessage: params.expectsCompletionMessage,
5273bestEffortDeliver: true,
5374directIdempotencyKey: params.directIdempotencyKey,
75+internalEvents: params.internalEvents,
5476});
5577}
5678@@ -163,6 +185,153 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
163185);
164186});
165187188+it("keeps announce-agent delivery primary for dormant completion events with child output", async () => {
189+const callGateway = createGatewayMock({
190+result: {
191+payloads: [{ text: "requester voice completion" }],
192+},
193+});
194+const sendMessage = createSendMessageMock();
195+const result = await deliverSlackThreadAnnouncement({
196+ callGateway,
197+ sendMessage,
198+sessionId: "requester-session-4",
199+isActive: false,
200+expectsCompletionMessage: true,
201+directIdempotencyKey: "announce-thread-fallback-1",
202+internalEvents: [
203+{
204+type: "task_completion",
205+source: "subagent",
206+childSessionKey: "agent:worker:subagent:child",
207+childSessionId: "child-session-id",
208+announceType: "subagent task",
209+taskLabel: "thread completion smoke",
210+status: "ok",
211+statusLabel: "completed successfully",
212+result: "child completion output",
213+replyInstruction: "Summarize the result.",
214+},
215+],
216+});
217+218+expect(result).toEqual(
219+expect.objectContaining({
220+delivered: true,
221+path: "direct",
222+}),
223+);
224+expect(callGateway).toHaveBeenCalledWith(
225+expect.objectContaining({
226+method: "agent",
227+params: expect.objectContaining({
228+deliver: true,
229+channel: "slack",
230+accountId: "acct-1",
231+to: "channel:C123",
232+threadId: "171.222",
233+bestEffortDeliver: true,
234+internalEvents: expect.any(Array),
235+}),
236+}),
237+);
238+expect(sendMessage).not.toHaveBeenCalled();
239+});
240+241+it("uses a direct thread fallback when announce-agent delivery fails", async () => {
242+const callGateway = vi.fn(async () => {
243+throw new Error("UNAVAILABLE: gateway lost final output");
244+}) as unknown as typeof runtimeCallGateway;
245+const sendMessage = createSendMessageMock();
246+const result = await deliverSlackThreadAnnouncement({
247+ callGateway,
248+ sendMessage,
249+sessionId: "requester-session-4",
250+isActive: false,
251+expectsCompletionMessage: true,
252+directIdempotencyKey: "announce-thread-fallback-1",
253+internalEvents: [
254+{
255+type: "task_completion",
256+source: "subagent",
257+childSessionKey: "agent:worker:subagent:child",
258+childSessionId: "child-session-id",
259+announceType: "subagent task",
260+taskLabel: "thread completion smoke",
261+status: "ok",
262+statusLabel: "completed successfully",
263+result: "child completion output",
264+replyInstruction: "Summarize the result.",
265+},
266+],
267+});
268+269+expect(result).toEqual(
270+expect.objectContaining({
271+delivered: true,
272+path: "direct-thread-fallback",
273+}),
274+);
275+expect(callGateway).toHaveBeenCalled();
276+expect(sendMessage).toHaveBeenCalledWith(
277+expect.objectContaining({
278+channel: "slack",
279+accountId: "acct-1",
280+to: "channel:C123",
281+threadId: "171.222",
282+content: "child completion output",
283+requesterSessionKey: "agent:main:slack:channel:C123:thread:171.222",
284+bestEffort: true,
285+idempotencyKey: "announce-thread-fallback-1",
286+}),
287+);
288+});
289+290+it("uses a direct thread fallback when announce-agent returns no visible output", async () => {
291+const callGateway = createGatewayMock({
292+result: {
293+payloads: [],
294+},
295+});
296+const sendMessage = createSendMessageMock();
297+const result = await deliverSlackThreadAnnouncement({
298+ callGateway,
299+ sendMessage,
300+sessionId: "requester-session-4",
301+isActive: false,
302+expectsCompletionMessage: true,
303+directIdempotencyKey: "announce-thread-fallback-empty",
304+internalEvents: [
305+{
306+type: "task_completion",
307+source: "subagent",
308+childSessionKey: "agent:worker:subagent:child",
309+childSessionId: "child-session-id",
310+announceType: "subagent task",
311+taskLabel: "thread completion smoke",
312+status: "ok",
313+statusLabel: "completed successfully",
314+result: "child completion output",
315+replyInstruction: "Summarize the result.",
316+},
317+],
318+});
319+320+expect(result).toEqual(
321+expect.objectContaining({
322+delivered: true,
323+path: "direct-thread-fallback",
324+}),
325+);
326+expect(callGateway).toHaveBeenCalled();
327+expect(sendMessage).toHaveBeenCalledWith(
328+expect.objectContaining({
329+content: "child completion output",
330+idempotencyKey: "announce-thread-fallback-empty",
331+}),
332+);
333+});
334+166335it("keeps direct external delivery for non-completion announces", async () => {
167336const callGateway = createGatewayMock();
168337await deliverSlackThreadAnnouncement({
@@ -188,3 +357,59 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
188357);
189358});
190359});
360+361+describe("extractThreadCompletionFallbackText", () => {
362+it("prefers task completion result text", () => {
363+expect(
364+extractThreadCompletionFallbackText([
365+{
366+type: "task_completion",
367+source: "subagent",
368+childSessionKey: "agent:worker:subagent:child",
369+announceType: "subagent task",
370+taskLabel: "sample task",
371+status: "ok",
372+statusLabel: "completed successfully",
373+result: "final child result",
374+replyInstruction: "Summarize the result.",
375+},
376+]),
377+).toBe("final child result");
378+});
379+380+it("falls back to task and status labels when result text is empty", () => {
381+expect(
382+extractThreadCompletionFallbackText([
383+{
384+type: "task_completion",
385+source: "subagent",
386+childSessionKey: "agent:worker:subagent:child",
387+announceType: "subagent task",
388+taskLabel: "sample task",
389+status: "ok",
390+statusLabel: "completed successfully",
391+result: " ",
392+replyInstruction: "Summarize the result.",
393+},
394+]),
395+).toBe("sample task: completed successfully");
396+});
397+398+it("falls back to the task label when result and status label are empty", () => {
399+expect(
400+extractThreadCompletionFallbackText([
401+{
402+type: "task_completion",
403+source: "subagent",
404+childSessionKey: "agent:worker:subagent:child",
405+announceType: "subagent task",
406+taskLabel: "sample task",
407+status: "ok",
408+statusLabel: " ",
409+result: " ",
410+replyInstruction: "Summarize the result.",
411+},
412+]),
413+).toBe("sample task");
414+});
415+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。