@@ -18,6 +18,8 @@ import {
1818 getChannelAgentToolMeta ,
1919 getPluginToolMeta ,
2020 type EmbeddedRunAttemptParams ,
21+ isDeliveredMessageToolOnlySourceReplyResult ,
22+ isDeliveredMessagingToolResult ,
2123 isReplaySafeToolCall ,
2224 isToolWrappedWithBeforeToolCallHook ,
2325 isToolResultError ,
@@ -63,9 +65,11 @@ type CodexDynamicToolHookContext = {
6365 currentChannelProvider ?: string ;
6466 currentChannelId ?: string ;
6567 currentMessagingTarget ?: string ;
68+ currentMessageId ?: string | number ;
6669 currentThreadId ?: string ;
6770 replyToMode ?: "off" | "first" | "all" | "batched" ;
6871 hasRepliedRef ?: { value : boolean } ;
72+ sourceReplyDeliveryMode ?: EmbeddedRunAttemptParams [ "sourceReplyDeliveryMode" ] ;
6973 onToolOutcome ?: EmbeddedRunAttemptParams [ "onToolOutcome" ] ;
7074 allocateToolOutcomeOrdinal ?: EmbeddedRunAttemptParams [ "allocateToolOutcomeOrdinal" ] ;
7175} ;
@@ -100,6 +104,225 @@ function applyCurrentMessageProvider(
100104 return { ...args , provider } ;
101105}
102106
107+ function normalizeRouteToken ( value : string | number | undefined ) : string | undefined {
108+ if ( typeof value === "number" ) {
109+ return Number . isFinite ( value ) ? String ( value ) : undefined ;
110+ }
111+ const normalized = value ?. trim ( ) . toLowerCase ( ) ;
112+ return normalized ? normalized : undefined ;
113+ }
114+
115+ function sourceRouteTokens ( hookContext : CodexDynamicToolHookContext | undefined ) : Set < string > {
116+ const tokens = new Set < string > ( ) ;
117+ const currentTarget = normalizeRouteToken ( hookContext ?. currentMessagingTarget ) ;
118+ const currentChannel = normalizeRouteToken ( hookContext ?. currentChannelId ) ;
119+ const currentProvider = normalizeRouteToken ( hookContext ?. currentChannelProvider ) ;
120+ if ( currentTarget ) {
121+ tokens . add ( currentTarget ) ;
122+ }
123+ if ( currentChannel ) {
124+ tokens . add ( currentChannel ) ;
125+ }
126+ const channelPrefixIndex = currentChannel ?. indexOf ( ":" ) ?? - 1 ;
127+ if ( channelPrefixIndex >= 0 && currentChannel ) {
128+ const unprefixedChannel = currentChannel . slice ( channelPrefixIndex + 1 ) ;
129+ if ( unprefixedChannel ) {
130+ tokens . add ( unprefixedChannel ) ;
131+ for ( const segment of unprefixedChannel . split ( / [ ; , ] / u) ) {
132+ const token = normalizeRouteToken ( segment ) ;
133+ if ( token ) {
134+ tokens . add ( token ) ;
135+ }
136+ }
137+ }
138+ }
139+ if ( currentProvider && currentChannel ?. startsWith ( `${ currentProvider } :` ) ) {
140+ const unprefixedChannel = currentChannel . slice ( currentProvider . length + 1 ) ;
141+ if ( unprefixedChannel ) {
142+ tokens . add ( unprefixedChannel ) ;
143+ }
144+ }
145+ return tokens ;
146+ }
147+
148+ function routeTokenMatchesSource (
149+ token : string | undefined ,
150+ hookContext : CodexDynamicToolHookContext | undefined ,
151+ ) : boolean {
152+ const normalized = normalizeRouteToken ( token ) ;
153+ return normalized !== undefined && sourceRouteTokens ( hookContext ) . has ( normalized ) ;
154+ }
155+
156+ function routeProviderMatchesSource (
157+ provider : string | undefined ,
158+ hookContext : CodexDynamicToolHookContext | undefined ,
159+ ) : boolean {
160+ const normalized = normalizeRouteToken ( provider ) ;
161+ if ( ! normalized ) {
162+ return false ;
163+ }
164+ const currentProvider = normalizeRouteToken ( hookContext ?. currentChannelProvider ) ;
165+ const currentChannel = normalizeRouteToken ( hookContext ?. currentChannelId ) ;
166+ return currentProvider === normalized || currentChannel ?. startsWith ( `${ normalized } :` ) === true ;
167+ }
168+
169+ function routeTokenMatchesCurrentMessage (
170+ token : string | number | undefined ,
171+ hookContext : CodexDynamicToolHookContext | undefined ,
172+ ) : boolean {
173+ const normalized = normalizeRouteToken ( token ) ;
174+ return (
175+ normalized !== undefined && normalized === normalizeRouteToken ( hookContext ?. currentMessageId )
176+ ) ;
177+ }
178+
179+ function readRouteToken ( record : Record < string , unknown > , key : string ) : string | number | undefined {
180+ const value = record [ key ] ;
181+ return typeof value === "string" || typeof value === "number" ? value : undefined ;
182+ }
183+
184+ function explicitRouteTokensMismatchCurrent (
185+ args : Record < string , unknown > ,
186+ keys : readonly string [ ] ,
187+ currentToken : string | number | undefined ,
188+ ) : boolean {
189+ const normalizedCurrent = normalizeRouteToken ( currentToken ) ;
190+ if ( ! normalizedCurrent ) {
191+ return false ;
192+ }
193+ return keys . some ( ( key ) => {
194+ const normalized = normalizeRouteToken ( readRouteToken ( args , key ) ) ;
195+ return normalized !== undefined && normalized !== normalizedCurrent ;
196+ } ) ;
197+ }
198+
199+ function explicitThreadRouteTargetsNonSource (
200+ args : Record < string , unknown > ,
201+ hookContext : CodexDynamicToolHookContext | undefined ,
202+ messagingTarget : MessagingToolSend | undefined ,
203+ ) : boolean {
204+ const normalizedCurrentThread = normalizeRouteToken ( hookContext ?. currentThreadId ) ;
205+ const explicitThreadTokens = [
206+ ...EXPLICIT_MESSAGE_THREAD_KEYS . map ( ( key ) => normalizeRouteToken ( readRouteToken ( args , key ) ) ) ,
207+ normalizeRouteToken ( messagingTarget ?. threadId ) ,
208+ ] . filter ( ( value ) : value is string => value !== undefined ) ;
209+
210+ if ( explicitThreadTokens . length === 0 ) {
211+ return false ;
212+ }
213+ return (
214+ normalizedCurrentThread === undefined ||
215+ explicitThreadTokens . some ( ( value ) => value !== normalizedCurrentThread )
216+ ) ;
217+ }
218+
219+ function replyReceiptMatchesCurrentMessage (
220+ value : unknown ,
221+ hookContext : CodexDynamicToolHookContext | undefined ,
222+ depth = 0 ,
223+ ) : boolean {
224+ if ( depth > 4 || value === null ) {
225+ return false ;
226+ }
227+ if ( typeof value === "string" ) {
228+ const trimmed = value . trim ( ) ;
229+ if ( ! trimmed || ! [ "{" , "[" ] . includes ( trimmed [ 0 ] ?? "" ) ) {
230+ return false ;
231+ }
232+ try {
233+ return replyReceiptMatchesCurrentMessage ( JSON . parse ( trimmed ) , hookContext , depth + 1 ) ;
234+ } catch {
235+ return false ;
236+ }
237+ }
238+ if ( typeof value !== "object" ) {
239+ return false ;
240+ }
241+ if ( Array . isArray ( value ) ) {
242+ return value . some ( ( item ) => replyReceiptMatchesCurrentMessage ( item , hookContext , depth + 1 ) ) ;
243+ }
244+ const record = value as Record < string , unknown > ;
245+ for ( const key of [ "repliedTo" , "replyTo" , "replyToId" , "replyToIdFull" ] ) {
246+ if (
247+ routeTokenMatchesCurrentMessage (
248+ typeof record [ key ] === "string" ? record [ key ] : undefined ,
249+ hookContext ,
250+ )
251+ ) {
252+ return true ;
253+ }
254+ }
255+ for ( const key of [
256+ "content" ,
257+ "details" ,
258+ "payload" ,
259+ "receipt" ,
260+ "result" ,
261+ "results" ,
262+ "sendResult" ,
263+ "text" ,
264+ ] ) {
265+ if ( replyReceiptMatchesCurrentMessage ( record [ key ] , hookContext , depth + 1 ) ) {
266+ return true ;
267+ }
268+ }
269+ return false ;
270+ }
271+
272+ function hasExplicitNonSourceMessageRoute (
273+ args : Record < string , unknown > ,
274+ hookContext : CodexDynamicToolHookContext | undefined ,
275+ messagingTarget : MessagingToolSend | undefined ,
276+ ) : boolean {
277+ const currentProvider = normalizeRouteToken ( hookContext ?. currentChannelProvider ) ;
278+ for ( const key of EXPLICIT_MESSAGE_PROVIDER_KEYS ) {
279+ const provider = normalizeRouteToken ( typeof args [ key ] === "string" ? args [ key ] : undefined ) ;
280+ if (
281+ provider &&
282+ currentProvider !== provider &&
283+ ! routeProviderMatchesSource ( provider , hookContext )
284+ ) {
285+ return true ;
286+ }
287+ }
288+ const targetValues = [
289+ ...EXPLICIT_MESSAGE_TARGET_KEYS . map ( ( key ) =>
290+ typeof args [ key ] === "string" ? args [ key ] : undefined ,
291+ ) ,
292+ ...( Array . isArray ( args . targets )
293+ ? args . targets . map ( ( value ) => ( typeof value === "string" ? value : undefined ) )
294+ : [ ] ) ,
295+ ] . filter ( ( value ) : value is string => normalizeRouteToken ( value ) !== undefined ) ;
296+ if ( explicitThreadRouteTargetsNonSource ( args , hookContext , messagingTarget ) ) {
297+ return true ;
298+ }
299+ if (
300+ explicitRouteTokensMismatchCurrent (
301+ args ,
302+ EXPLICIT_MESSAGE_REPLY_KEYS ,
303+ hookContext ?. currentMessageId ,
304+ )
305+ ) {
306+ return true ;
307+ }
308+ if (
309+ messagingTarget ?. to !== undefined &&
310+ ! routeTokenMatchesSource ( messagingTarget . to , hookContext )
311+ ) {
312+ return true ;
313+ }
314+ if ( messagingTarget ?. to !== undefined ) {
315+ return false ;
316+ }
317+ if ( targetValues . length === 0 ) {
318+ return false ;
319+ }
320+ if ( targetValues . some ( ( value ) => ! routeTokenMatchesSource ( value , hookContext ) ) ) {
321+ return true ;
322+ }
323+ return false ;
324+ }
325+
103326/** Runtime bridge returned to Codex app-server attempt code. */
104327export type CodexDynamicToolBridge = {
105328 availableSpecs : CodexDynamicToolSpec [ ] ;
@@ -114,6 +337,7 @@ export type CodexDynamicToolBridge = {
114337 ) => Promise < CodexDynamicToolCallResponse > ;
115338 telemetry : {
116339 didSendViaMessagingTool : boolean ;
340+ didDeliverSourceReplyViaMessageTool : boolean ;
117341 messagingToolSentTexts : string [ ] ;
118342 messagingToolSentMediaUrls : string [ ] ;
119343 messagingToolSentTargets : MessagingToolSend [ ] ;
@@ -132,6 +356,10 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
132356// Keep OpenClaw session spawning searchable in Codex mode so Codex's native
133357// spawn_agent remains the primary Codex subagent surface.
134358const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set ( [ "sessions_yield" ] ) ;
359+ const EXPLICIT_MESSAGE_PROVIDER_KEYS = [ "channel" , "provider" ] ;
360+ const EXPLICIT_MESSAGE_TARGET_KEYS = [ "target" , "to" , "channelId" ] ;
361+ const EXPLICIT_MESSAGE_THREAD_KEYS = [ "threadId" , "thread_id" , "messageThreadId" , "topicId" ] ;
362+ const EXPLICIT_MESSAGE_REPLY_KEYS = [ "replyTo" , "replyToId" , "replyToIdFull" ] ;
135363const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000 ;
136364
137365/**
@@ -176,6 +404,7 @@ export function createCodexDynamicToolBridge(params: {
176404 emitQuarantinedDynamicToolDiagnostics ( quarantinedTools , params . hookContext ) ;
177405 const telemetry : CodexDynamicToolBridge [ "telemetry" ] = {
178406 didSendViaMessagingTool : false ,
407+ didDeliverSourceReplyViaMessageTool : false ,
179408 messagingToolSentTexts : [ ] ,
180409 messagingToolSentMediaUrls : [ ] ,
181410 messagingToolSentTargets : [ ] ,
@@ -333,10 +562,9 @@ export function createCodexDynamicToolBridge(params: {
333562 executedArgs ,
334563 params . hookContext ?. currentChannelProvider ,
335564 ) ;
336- const messagingTarget =
337- isMessagingTool ( toolName ) && isMessagingToolSendAction ( toolName , executedArgs )
338- ? extractMessagingToolSend ( toolName , messagingTelemetryArgs , messagingContext )
339- : undefined ;
565+ const messagingTarget = isMessagingTool ( toolName )
566+ ? extractMessagingToolSend ( toolName , messagingTelemetryArgs , messagingContext )
567+ : undefined ;
340568 const confirmedMessagingTarget =
341569 ! rawIsError && messagingTarget
342570 ? extractMessagingToolSendResult ( messagingTarget , telemetryRawResult )
@@ -358,12 +586,53 @@ export function createCodexDynamicToolBridge(params: {
358586 } ,
359587 terminalType ,
360588 ) ;
589+ const blocksSourceReplyTermination = hasExplicitNonSourceMessageRoute (
590+ executedArgs ,
591+ params . hookContext ,
592+ confirmedMessagingTarget ,
593+ ) ;
594+ const deliveredSourceReply = isDeliveredMessageToolOnlySourceReplyResult ( {
595+ sourceReplyDeliveryMode : params . hookContext ?. sourceReplyDeliveryMode ,
596+ toolName,
597+ args : executedArgs ,
598+ result,
599+ hookResult : rawResult ,
600+ isError : resultIsError ,
601+ allowExplicitSourceRoute : ! blocksSourceReplyTermination ,
602+ } ) ;
603+ const receiptConfirmedSourceReply =
604+ params . hookContext ?. sourceReplyDeliveryMode === "message_tool_only" &&
605+ toolName === "message" &&
606+ normalizeRouteToken (
607+ typeof executedArgs . action === "string" ? executedArgs . action : undefined ,
608+ ) === "reply" &&
609+ ! resultIsError &&
610+ ! blocksSourceReplyTermination &&
611+ isDeliveredMessagingToolResult ( {
612+ toolName,
613+ args : executedArgs ,
614+ result,
615+ hookResult : rawResult ,
616+ isError : resultIsError ,
617+ } ) &&
618+ ( replyReceiptMatchesCurrentMessage ( rawResult , params . hookContext ) ||
619+ replyReceiptMatchesCurrentMessage ( result , params . hookContext ) ) ;
620+ const toolConfirmedSourceReply =
621+ params . hookContext ?. sourceReplyDeliveryMode === "message_tool_only" &&
622+ toolName === "message" &&
623+ ! resultIsError &&
624+ ( rawResult . terminate === true || result . terminate === true ) ;
625+ if ( deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply ) {
626+ telemetry . didDeliverSourceReplyViaMessageTool = true ;
627+ }
361628 withDynamicToolTermination (
362629 response ,
363630 rawResult . terminate === true ||
364631 result . terminate === true ||
365632 isToolResultYield ( rawResult ) ||
366- isToolResultYield ( result ) ,
633+ isToolResultYield ( result ) ||
634+ deliveredSourceReply ||
635+ receiptConfirmedSourceReply ,
367636 ) ;
368637 const asyncStarted =
369638 isAsyncStartedToolResult ( rawResult ) || isAsyncStartedToolResult ( result ) ;
@@ -801,9 +1070,22 @@ function collectToolTelemetry(params: {
8011070 }
8021071 }
8031072 }
1073+ if ( ! isMessagingTool ( params . toolName ) ) {
1074+ return ;
1075+ }
1076+ const isMessagingSendAction = isMessagingToolSendAction ( params . toolName , params . args ) ;
1077+ if ( ! isMessagingSendAction && ! params . messagingTarget ) {
1078+ return ;
1079+ }
8041080 if (
805- ! isMessagingTool ( params . toolName ) ||
806- ! isMessagingToolSendAction ( params . toolName , params . args )
1081+ ! isMessagingSendAction &&
1082+ ! isDeliveredMessagingToolResult ( {
1083+ toolName : params . toolName ,
1084+ args : params . args ,
1085+ result : params . result ,
1086+ hookResult : params . mediaTrustResult ,
1087+ isError : params . isError ,
1088+ } )
8071089 ) {
8081090 return ;
8091091 }
0 commit comments