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" ;
513
614afterEach ( ( ) => {
@@ -14,8 +22,18 @@ const slackThreadOrigin = {
1422 threadId : "171.222" ,
1523} as const ;
1624
17- 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}
2038
2139async function deliverSlackThreadAnnouncement ( params : {
@@ -25,6 +43,8 @@ async function deliverSlackThreadAnnouncement(params: {
2543 expectsCompletionMessage : boolean ;
2644 directIdempotencyKey : string ;
2745 queueEmbeddedPiMessage ?: ( sessionId : string , message : string ) => boolean ;
46+ sendMessage ?: typeof runtimeSendMessage ;
47+ internalEvents ?: AgentInternalEvent [ ] ;
2848} ) {
2949 __testing . setDepsForTest ( {
3050 callGateway : 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 } ) ;
4061
4162 return deliverSubagentAnnouncement ( {
@@ -51,6 +72,7 @@ async function deliverSlackThreadAnnouncement(params: {
5172 expectsCompletionMessage : params . expectsCompletionMessage ,
5273 bestEffortDeliver : true ,
5374 directIdempotencyKey : params . directIdempotencyKey ,
75+ internalEvents : params . internalEvents ,
5476 } ) ;
5577}
5678
@@ -163,6 +185,153 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
163185 ) ;
164186 } ) ;
165187
188+ 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+
166335 it ( "keeps direct external delivery for non-completion announces" , async ( ) => {
167336 const callGateway = createGatewayMock ( ) ;
168337 await 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+ } ) ;
0 commit comments