@@ -41,6 +41,7 @@ vi.mock('@/lib/webhooks/slack-stream-sessions', () => ({
4141
4242import { ExecuteEventProjection } from '@/lib/mothership/request/lifecycle/execute-events'
4343import type { SlackStreamChunk } from '@/lib/webhooks/slack-agent-api'
44+ import { SlackDeliveryError } from '@/lib/webhooks/slack-delivery-error'
4445import { SlackExecutionStreamController } from '@/lib/webhooks/slack-execution-stream'
4546import type { SlackStreamResponseConfig } from '@/lib/webhooks/slack-stream-config'
4647import { type AgentStreamEvent , createAgentEventReadableStream } from '@/providers/stream-events'
@@ -475,7 +476,7 @@ describe('SlackExecutionStreamController', () => {
475476 . join ( '' )
476477 }
477478
478- function enforceSlackMessageLimit ( ) {
479+ function enforceSlackMessageLimit ( limit = 12_000 ) {
479480 const messages = new Map <
480481 string ,
481482 { text : string ; chunks : SlackStreamChunk [ ] ; stopped : boolean }
@@ -493,8 +494,8 @@ describe('SlackExecutionStreamController', () => {
493494 . filter ( ( chunk ) => chunk . type === 'markdown_text' )
494495 . map ( ( chunk ) => chunk . text )
495496 . join ( '' )
496- if ( message . text . length + text . length > 12_000 ) {
497- throw new Error ( 'Slack chat.appendStream: msg_too_long')
497+ if ( message . text . length + text . length > limit ) {
498+ throw new SlackDeliveryError ( ' chat.appendStream' , 'rejected' , ' msg_too_long', 200 )
498499 }
499500 expect ( text . isWellFormed ( ) ) . toBe ( true )
500501 message . text += text
@@ -674,6 +675,100 @@ describe('SlackExecutionStreamController', () => {
674675 }
675676 )
676677
678+ it ( 'continues a definitively rejected 11938 + 62 boundary append without replaying accepted text' , async ( ) => {
679+ const messages = enforceSlackMessageLimit ( 11_999 )
680+ const prefix = 'a' . repeat ( 11_938 )
681+ const suffix = `${ 'b' . repeat ( 3_000 ) } Complete ending.`
682+ const { controller } = await deliver (
683+ [
684+ { type : 'tool_call_start' , id : 'read' , name : 'read' } ,
685+ { type : 'text_delta' , text : prefix , turn : 'pending' } ,
686+ { type : 'text_delta' , text : suffix , turn : 'pending' } ,
687+ { type : 'tool_call_end' , id : 'read' , name : 'read' , status : 'success' } ,
688+ { type : 'turn_end' , turn : 'final' } ,
689+ ] ,
690+ prefix + suffix
691+ )
692+ controller . assertSucceeded ( )
693+ expect ( [ ...messages . values ( ) ] . map ( ( message ) => message . text ) ) . toEqual ( [ prefix , suffix ] )
694+ const rejected = mockAppendSlackAgentStream . mock . calls . filter (
695+ ( call ) =>
696+ call [ 2 ] === 'message-0' &&
697+ call [ 3 ] . some (
698+ ( chunk : SlackStreamChunk ) =>
699+ chunk . type === 'markdown_text' && chunk . text === suffix . slice ( 0 , 62 )
700+ )
701+ )
702+ expect ( rejected ) . toHaveLength ( 1 )
703+ expect ( [ ...messages . values ( ) ] . every ( ( message ) => message . stopped ) ) . toBe ( true )
704+ const toolChunks = messages
705+ . get ( 'message-0' ) !
706+ . chunks . filter ( ( chunk ) => chunk . type === 'task_update' && chunk . id . endsWith ( '-tool-read' ) )
707+ expect ( toolChunks . map ( ( chunk ) => chunk . status ) ) . toEqual ( [ 'in_progress' , 'complete' ] )
708+ } )
709+
710+ it ( 'reduces a definitively oversized first append and still delivers all final-only text' , async ( ) => {
711+ const messages = enforceSlackMessageLimit ( 11_999 )
712+ const answer = '🚀' . repeat ( 13_000 )
713+ const { controller } = await deliver ( [ ] , answer )
714+ controller . assertSucceeded ( )
715+ expect ( [ ...messages . values ( ) ] . map ( ( message ) => message . text ) . join ( '' ) ) . toBe ( answer )
716+ expect ( [ ...messages . values ( ) ] . every ( ( message ) => message . text && message . stopped ) ) . toBe ( true )
717+ } )
718+
719+ it . each ( [
720+ new SlackDeliveryError ( 'chat.appendStream' , 'uncertain' , 'msg_too_long' ) ,
721+ new SlackDeliveryError ( 'chat.appendStream' , 'rejected' , 'ratelimited' , 429 ) ,
722+ new SlackDeliveryError ( 'chat.appendStream' , 'rejected' , 'missing_scope' , 200 ) ,
723+ ] ) ( 'does not replay ambiguous or non-size append errors: %s' , async ( error ) => {
724+ const messages = enforceSlackMessageLimit ( )
725+ mockAppendSlackAgentStream . mockRejectedValueOnce ( error )
726+ const { controller } = await deliver ( [ { type : 'text_delta' , text : 'answer' } ] , 'answer' )
727+ expect ( ( ) => controller . assertSucceeded ( ) ) . toThrow ( error )
728+ expect ( mockAppendSlackAgentStream ) . toHaveBeenCalledTimes ( 1 )
729+ expect ( mockStartSlackAgentStream ) . toHaveBeenCalledTimes ( 1 )
730+ expect ( messages . get ( 'message-0' ) ! . stopped ) . toBe ( true )
731+ } )
732+
733+ it ( 'bounds size recovery when even a single character is rejected' , async ( ) => {
734+ const messages = enforceSlackMessageLimit ( 0 )
735+ const { controller } = await deliver ( [ ] , 'abcd' )
736+ expect ( ( ) => controller . assertSucceeded ( ) ) . toThrow ( 'msg_too_long' )
737+ expect ( mockAppendSlackAgentStream . mock . calls . map ( ( call ) => call [ 3 ] ) ) . toEqual ( [
738+ [ { type : 'markdown_text' , text : 'abcd' } ] ,
739+ [ { type : 'markdown_text' , text : 'ab' } ] ,
740+ [ { type : 'markdown_text' , text : 'a' } ] ,
741+ ] )
742+ expect ( mockStartSlackAgentStream ) . toHaveBeenCalledTimes ( 1 )
743+ expect ( messages . get ( 'message-0' ) ! . stopped ) . toBe ( true )
744+ } )
745+
746+ it ( 'stops recovery if the continuation append has an uncertain outcome' , async ( ) => {
747+ const messages = enforceSlackMessageLimit ( )
748+ const prefix = 'a' . repeat ( 11_938 )
749+ mockAppendSlackAgentStream
750+ . mockImplementationOnce ( async ( ) => {
751+ messages . get ( 'message-0' ) ! . text = prefix
752+ } )
753+ . mockRejectedValueOnce (
754+ new SlackDeliveryError ( 'chat.appendStream' , 'rejected' , 'msg_too_long' )
755+ )
756+ . mockRejectedValueOnce (
757+ new SlackDeliveryError ( 'chat.appendStream' , 'uncertain' , 'transport_failed' )
758+ )
759+ const { controller } = await deliver (
760+ [
761+ { type : 'text_delta' , text : prefix , turn : 'pending' } ,
762+ { type : 'text_delta' , text : 'b' . repeat ( 150 ) , turn : 'pending' } ,
763+ ] ,
764+ prefix + 'b' . repeat ( 150 )
765+ )
766+ expect ( ( ) => controller . assertSucceeded ( ) ) . toThrow ( 'transport_failed' )
767+ expect ( mockAppendSlackAgentStream ) . toHaveBeenCalledTimes ( 3 )
768+ expect ( mockStartSlackAgentStream ) . toHaveBeenCalledTimes ( 2 )
769+ expect ( [ ...messages . values ( ) ] . every ( ( message ) => message . stopped ) ) . toBe ( true )
770+ } )
771+
677772 it ( 'counts commentary across turns and pairs tools on their original message after rollover' , async ( ) => {
678773 const messages = enforceSlackMessageLimit ( )
679774 const commentary = 'c' . repeat ( 11_800 )
0 commit comments