@@ -130,6 +130,12 @@ export async function appendSlackStream(params: AppendSlackStreamParams): Promis
130130 *
131131 * After calling this the stream message becomes a normal Slack message.
132132 * Optionally include final text to append before stopping.
133+ *
134+ * If Slack's `chat.stopStream` responds with a known benign finalize error
135+ * (e.g. `user_not_found` for Slack Connect recipients - see issue #70295),
136+ * any text already delivered via `append()` stays visible and the session
137+ * is marked stopped. Other Slack API errors still propagate so the caller
138+ * can record them.
133139 */
134140export async function stopSlackStream ( params : StopSlackStreamParams ) : Promise < void > {
135141 const { session, text } = params ;
@@ -147,7 +153,73 @@ export async function stopSlackStream(params: StopSlackStreamParams): Promise<vo
147153 } `,
148154 ) ;
149155
150- await session . streamer . stop ( text ? { markdown_text : text } : undefined ) ;
156+ try {
157+ await session . streamer . stop ( text ? { markdown_text : text } : undefined ) ;
158+ } catch ( err ) {
159+ if ( isBenignSlackFinalizeError ( err ) ) {
160+ logVerbose (
161+ `slack-stream: finalize rejected by Slack (${ formatSlackError ( err ) } ); ` +
162+ "appended text remains visible, treating stream as stopped" ,
163+ ) ;
164+ return ;
165+ }
166+ throw err ;
167+ }
151168
152169 logVerbose ( "slack-stream: stream stopped" ) ;
153170}
171+
172+ // ---------------------------------------------------------------------------
173+ // Finalize error classification
174+ // ---------------------------------------------------------------------------
175+
176+ /**
177+ * Slack API error codes that indicate `chat.stopStream` cannot finalize the
178+ * stream for the current recipient/team, but any `chat.appendStream` calls
179+ * that already landed are still visible to the user. Treat these as benign
180+ * at the dispatch layer so the reply is not reported as an error when text
181+ * did get through.
182+ */
183+ const BENIGN_SLACK_FINALIZE_ERROR_CODES = new Set < string > ( [
184+ // Slack Connect recipients: finalize fails because the external user id
185+ // is not resolvable in the host workspace (#70295).
186+ "user_not_found" ,
187+ // Slack Connect team mismatch in shared channels.
188+ "team_not_found" ,
189+ // DMs that closed between stream start and stop.
190+ "missing_recipient_user_id" ,
191+ ] ) ;
192+
193+ function isBenignSlackFinalizeError ( err : unknown ) : boolean {
194+ const code = extractSlackErrorCode ( err ) ;
195+ return code !== undefined && BENIGN_SLACK_FINALIZE_ERROR_CODES . has ( code ) ;
196+ }
197+
198+ function extractSlackErrorCode ( err : unknown ) : string | undefined {
199+ if ( ! err || typeof err !== "object" ) {
200+ return undefined ;
201+ }
202+ const record = err as Record < string , unknown > ;
203+ // @slack /web-api errors expose `data.error` with the Slack error code.
204+ if ( record . data && typeof record . data === "object" ) {
205+ const inner = ( record . data as Record < string , unknown > ) . error ;
206+ if ( typeof inner === "string" ) {
207+ return inner ;
208+ }
209+ }
210+ // Fallback: parse from message string ("An API error occurred: user_not_found").
211+ const message = typeof record . message === "string" ? record . message : "" ;
212+ const match = message . match ( / A n A P I e r r o r o c c u r r e d : \s * ( [ a - z _ ] [ a - z 0 - 9 _ ] * ) / i) ;
213+ return match ?. [ 1 ] ;
214+ }
215+
216+ function formatSlackError ( err : unknown ) : string {
217+ const code = extractSlackErrorCode ( err ) ;
218+ if ( code ) {
219+ return code ;
220+ }
221+ if ( err instanceof Error ) {
222+ return err . message ;
223+ }
224+ return String ( err ) ;
225+ }
0 commit comments