@@ -207,6 +207,7 @@ fn normalize_root_path(value: &str) -> String {
207207struct ThreadListEntry {
208208 thread_id : String ,
209209 cwd : Option < String > ,
210+ is_memory_consolidation : bool ,
210211}
211212
212213fn extract_thread_entries_from_thread_list_result ( value : & Value ) -> Vec < ThreadListEntry > {
@@ -247,7 +248,17 @@ fn extract_thread_entries_from_thread_list_result(value: &Value) -> Vec<ThreadLi
247248 . map ( |value| value. to_string ( ) )
248249 } ) ;
249250 if let Some ( thread_id) = thread_id {
250- out. push ( ThreadListEntry { thread_id, cwd } ) ;
251+ let source = object
252+ . get ( "source" )
253+ . or_else ( || object. get ( "thread" ) . and_then ( |thread| thread. get ( "source" ) ) ) ;
254+ let is_memory_consolidation = source
255+ . and_then ( source_subagent_kind)
256+ . is_some_and ( |kind| kind == "memory_consolidation" ) ;
257+ out. push ( ThreadListEntry {
258+ thread_id,
259+ cwd,
260+ is_memory_consolidation,
261+ } ) ;
251262 }
252263
253264 for key in [ "threads" , "items" , "results" , "data" ] {
@@ -294,6 +305,94 @@ fn resolve_workspace_for_cwd(
294305 . map ( |( workspace_id, _) | workspace_id. clone ( ) )
295306}
296307
308+ fn normalize_subagent_kind ( value : & str ) -> String {
309+ let mut normalized = value. trim ( ) . to_ascii_lowercase ( ) . replace ( [ ' ' , '-' ] , "_" ) ;
310+ if let Some ( stripped) = normalized. strip_prefix ( "subagent_" ) {
311+ normalized = stripped. to_string ( ) ;
312+ } else if let Some ( stripped) = normalized. strip_prefix ( "sub_agent_" ) {
313+ normalized = stripped. to_string ( ) ;
314+ }
315+ normalized
316+ }
317+
318+ fn source_subagent_kind ( source : & Value ) -> Option < String > {
319+ if let Some ( raw) = source. as_str ( ) {
320+ let normalized = normalize_subagent_kind ( raw) ;
321+ return if normalized. is_empty ( ) {
322+ None
323+ } else {
324+ Some ( normalized)
325+ } ;
326+ }
327+ let source_obj = source. as_object ( ) ?;
328+ let sub_agent = source_obj
329+ . get ( "subAgent" )
330+ . or_else ( || source_obj. get ( "sub_agent" ) )
331+ . or_else ( || source_obj. get ( "subagent" ) ) ?;
332+
333+ if let Some ( raw) = sub_agent. as_str ( ) {
334+ let normalized = normalize_subagent_kind ( raw) ;
335+ return if normalized. is_empty ( ) {
336+ None
337+ } else {
338+ Some ( normalized)
339+ } ;
340+ }
341+ let sub_agent_obj = sub_agent. as_object ( ) ?;
342+ if let Some ( explicit) = sub_agent_obj
343+ . get ( "kind" )
344+ . or_else ( || sub_agent_obj. get ( "type" ) )
345+ . or_else ( || sub_agent_obj. get ( "name" ) )
346+ . or_else ( || sub_agent_obj. get ( "id" ) )
347+ . and_then ( Value :: as_str)
348+ {
349+ let normalized = normalize_subagent_kind ( explicit) ;
350+ return if normalized. is_empty ( ) {
351+ None
352+ } else {
353+ Some ( normalized)
354+ } ;
355+ }
356+
357+ let candidate_keys: Vec < & String > = sub_agent_obj
358+ . keys ( )
359+ . filter ( |key| key. as_str ( ) != "thread_spawn" && key. as_str ( ) != "threadSpawn" )
360+ . collect ( ) ;
361+ if candidate_keys. len ( ) != 1 {
362+ return None ;
363+ }
364+ let normalized = normalize_subagent_kind ( candidate_keys[ 0 ] ) ;
365+ if normalized. is_empty ( ) {
366+ None
367+ } else {
368+ Some ( normalized)
369+ }
370+ }
371+
372+ fn thread_started_is_memory_consolidation ( value : & Value ) -> bool {
373+ value
374+ . get ( "params" )
375+ . and_then ( |params| {
376+ params
377+ . get ( "thread" )
378+ . and_then ( |thread| thread. get ( "source" ) )
379+ . or_else ( || params. get ( "source" ) )
380+ } )
381+ . and_then ( source_subagent_kind)
382+ . is_some_and ( |kind| kind == "memory_consolidation" )
383+ }
384+
385+ fn should_suppress_hidden_thread_event (
386+ method_name : Option < & str > ,
387+ has_result_or_error : bool ,
388+ ) -> bool {
389+ !has_result_or_error
390+ && !matches ! (
391+ method_name,
392+ Some ( "thread/archived" ) | Some ( "codex/backgroundThread" )
393+ )
394+ }
395+
297396fn is_global_workspace_notification ( method : & str ) -> bool {
298397 matches ! (
299398 method,
@@ -339,6 +438,7 @@ pub(crate) struct WorkspaceSession {
339438 pub ( crate ) pending : Mutex < HashMap < u64 , oneshot:: Sender < Value > > > ,
340439 pub ( crate ) request_context : Mutex < HashMap < u64 , RequestContext > > ,
341440 pub ( crate ) thread_workspace : Mutex < HashMap < String , String > > ,
441+ pub ( crate ) hidden_thread_ids : Mutex < HashSet < String > > ,
342442 pub ( crate ) next_id : AtomicU64 ,
343443 /// Callbacks for background threads - events for these threadIds are sent through the channel
344444 pub ( crate ) background_thread_callbacks : Mutex < HashMap < String , mpsc:: UnboundedSender < Value > > > ,
@@ -663,8 +763,8 @@ pub(crate) async fn spawn_workspace_session<E: EventSink>(
663763 vec ! [ "app-server" . to_string( ) ] ,
664764 ) ?;
665765 command. current_dir ( & entry. path ) ;
666- if let Some ( codex_home ) = codex_home {
667- command. env ( "CODEX_HOME" , codex_home ) ;
766+ if let Some ( path ) = codex_home. as_ref ( ) {
767+ command. env ( "CODEX_HOME" , path ) ;
668768 }
669769 command. stdin ( std:: process:: Stdio :: piped ( ) ) ;
670770 command. stdout ( std:: process:: Stdio :: piped ( ) ) ;
@@ -682,6 +782,7 @@ pub(crate) async fn spawn_workspace_session<E: EventSink>(
682782 pending : Mutex :: new ( HashMap :: new ( ) ) ,
683783 request_context : Mutex :: new ( HashMap :: new ( ) ) ,
684784 thread_workspace : Mutex :: new ( HashMap :: new ( ) ) ,
785+ hidden_thread_ids : Mutex :: new ( HashSet :: new ( ) ) ,
685786 next_id : AtomicU64 :: new ( 1 ) ,
686787 background_thread_callbacks : Mutex :: new ( HashMap :: new ( ) ) ,
687788 owner_workspace_id : entry. id . clone ( ) ,
@@ -753,8 +854,14 @@ pub(crate) async fn spawn_workspace_session<E: EventSink>(
753854 let thread_entries = extract_thread_entries_from_thread_list_result ( & value) ;
754855 if !thread_entries. is_empty ( ) {
755856 let workspace_roots = session_clone. workspace_roots . lock ( ) . await . clone ( ) ;
857+ let mut hidden_thread_ids = Vec :: new ( ) ;
756858 let mut thread_workspace = session_clone. thread_workspace . lock ( ) . await ;
757859 for entry in thread_entries {
860+ if entry. is_memory_consolidation {
861+ thread_workspace. remove ( & entry. thread_id ) ;
862+ hidden_thread_ids. push ( entry. thread_id ) ;
863+ continue ;
864+ }
758865 let mapped_workspace = entry
759866 . cwd
760867 . as_deref ( )
@@ -763,24 +870,70 @@ pub(crate) async fn spawn_workspace_session<E: EventSink>(
763870 thread_workspace. insert ( entry. thread_id , workspace_id) ;
764871 }
765872 }
873+ drop ( thread_workspace) ;
874+ if !hidden_thread_ids. is_empty ( ) {
875+ let mut hidden = session_clone. hidden_thread_ids . lock ( ) . await ;
876+ for thread_id in hidden_thread_ids {
877+ hidden. insert ( thread_id) ;
878+ }
879+ }
766880 }
767881 }
768882
769- let routed_workspace_id = if let Some ( ref tid) = thread_id {
883+ let mapped_thread_workspace = if let Some ( ref tid) = thread_id {
770884 session_clone
771885 . thread_workspace
772886 . lock ( )
773887 . await
774888 . get ( tid)
775889 . cloned ( )
776- . or_else ( || request_workspace. clone ( ) )
777- . unwrap_or_else ( || fallback_workspace_id. clone ( ) )
778890 } else {
779- request_workspace
780- . clone ( )
781- . unwrap_or_else ( || fallback_workspace_id. clone ( ) )
891+ None
782892 } ;
783893
894+ let routed_workspace_id = mapped_thread_workspace
895+ . or_else ( || request_workspace. clone ( ) )
896+ . unwrap_or_else ( || fallback_workspace_id. clone ( ) ) ;
897+
898+ if let Some ( ref tid) = thread_id {
899+ if method_name == Some ( "codex/backgroundThread" ) {
900+ let action = value
901+ . get ( "params" )
902+ . and_then ( |params| params. get ( "action" ) )
903+ . and_then ( Value :: as_str)
904+ . unwrap_or ( "hide" ) ;
905+ if action. eq_ignore_ascii_case ( "hide" ) {
906+ session_clone. hidden_thread_ids . lock ( ) . await . insert ( tid. clone ( ) ) ;
907+ }
908+ } else if method_name == Some ( "thread/started" )
909+ && thread_started_is_memory_consolidation ( & value)
910+ {
911+ session_clone. hidden_thread_ids . lock ( ) . await . insert ( tid. clone ( ) ) ;
912+ let payload = AppServerEvent {
913+ workspace_id : routed_workspace_id. clone ( ) ,
914+ message : json ! ( {
915+ "method" : "codex/backgroundThread" ,
916+ "params" : {
917+ "threadId" : tid,
918+ "action" : "hide"
919+ }
920+ } ) ,
921+ } ;
922+ event_sink_clone. emit_app_server_event ( payload) ;
923+ continue ;
924+ }
925+
926+ let should_suppress_hidden_thread = {
927+ let hidden = session_clone. hidden_thread_ids . lock ( ) . await ;
928+ hidden. contains ( tid)
929+ } ;
930+ if should_suppress_hidden_thread
931+ && should_suppress_hidden_thread_event ( method_name, has_result_or_error)
932+ {
933+ continue ;
934+ }
935+ }
936+
784937 if matches ! ( method_name, Some ( "item/started" ) | Some ( "item/completed" ) ) {
785938 let related_thread_ids = extract_related_thread_ids ( & value) ;
786939 if !related_thread_ids. is_empty ( ) {
@@ -796,6 +949,7 @@ pub(crate) async fn spawn_workspace_session<E: EventSink>(
796949 if method_name == Some ( "thread/archived" ) {
797950 if let Some ( ref tid) = thread_id {
798951 session_clone. thread_workspace . lock ( ) . await . remove ( tid) ;
952+ session_clone. hidden_thread_ids . lock ( ) . await . remove ( tid) ;
799953 }
800954 }
801955
@@ -953,6 +1107,8 @@ mod tests {
9531107 use super :: {
9541108 build_initialize_params, extract_related_thread_ids, extract_thread_entries_from_thread_list_result,
9551109 extract_thread_id, normalize_root_path, resolve_workspace_for_cwd,
1110+ should_suppress_hidden_thread_event, source_subagent_kind,
1111+ thread_started_is_memory_consolidation,
9561112 } ;
9571113 use std:: collections:: HashMap ;
9581114 use serde_json:: json;
@@ -993,16 +1149,22 @@ mod tests {
9931149 "result" : {
9941150 "data" : [
9951151 { "id" : "thread-a" , "cwd" : "/tmp/a" } ,
996- { "threadId" : "thread-b" , "cwd" : "/tmp/b" }
1152+ {
1153+ "threadId" : "thread-b" ,
1154+ "cwd" : "/tmp/b" ,
1155+ "source" : { "subAgent" : "memory_consolidation" }
1156+ }
9971157 ]
9981158 }
9991159 } ) ;
10001160 let entries = extract_thread_entries_from_thread_list_result ( & value) ;
10011161 assert_eq ! ( entries. len( ) , 2 ) ;
10021162 assert_eq ! ( entries[ 0 ] . thread_id, "thread-a" ) ;
10031163 assert_eq ! ( entries[ 0 ] . cwd. as_deref( ) , Some ( "/tmp/a" ) ) ;
1164+ assert ! ( !entries[ 0 ] . is_memory_consolidation) ;
10041165 assert_eq ! ( entries[ 1 ] . thread_id, "thread-b" ) ;
10051166 assert_eq ! ( entries[ 1 ] . cwd. as_deref( ) , Some ( "/tmp/b" ) ) ;
1167+ assert ! ( entries[ 1 ] . is_memory_consolidation) ;
10061168 }
10071169
10081170 #[ test]
@@ -1115,4 +1277,100 @@ mod tests {
11151277 Some ( "ws-child" . to_string( ) )
11161278 ) ;
11171279 }
1280+
1281+ #[ test]
1282+ fn source_subagent_kind_reads_string_variants ( ) {
1283+ assert_eq ! (
1284+ source_subagent_kind( & json!( "subagent-memory-consolidation" ) ) ,
1285+ Some ( "memory_consolidation" . to_string( ) )
1286+ ) ;
1287+ assert_eq ! (
1288+ source_subagent_kind( & json!( "sub_agent_memory_consolidation" ) ) ,
1289+ Some ( "memory_consolidation" . to_string( ) )
1290+ ) ;
1291+ }
1292+
1293+ #[ test]
1294+ fn source_subagent_kind_reads_nested_subagent_object_keys ( ) {
1295+ let source = json ! ( {
1296+ "subAgent" : {
1297+ "memory_consolidation" : {
1298+ "thread_spawn" : { "parent_thread_id" : "thread-parent" }
1299+ }
1300+ }
1301+ } ) ;
1302+ assert_eq ! (
1303+ source_subagent_kind( & source) ,
1304+ Some ( "memory_consolidation" . to_string( ) )
1305+ ) ;
1306+ }
1307+
1308+ #[ test]
1309+ fn thread_started_memory_consolidation_detects_thread_source ( ) {
1310+ let value = json ! ( {
1311+ "method" : "thread/started" ,
1312+ "params" : {
1313+ "thread" : {
1314+ "id" : "thread-1" ,
1315+ "source" : {
1316+ "subagent" : "memory_consolidation"
1317+ }
1318+ }
1319+ }
1320+ } ) ;
1321+ assert ! ( thread_started_is_memory_consolidation( & value) ) ;
1322+ }
1323+
1324+ #[ test]
1325+ fn thread_started_memory_consolidation_detects_params_source_fallback ( ) {
1326+ let value = json ! ( {
1327+ "method" : "thread/started" ,
1328+ "params" : {
1329+ "threadId" : "thread-1" ,
1330+ "source" : {
1331+ "subAgent" : "memory_consolidation"
1332+ }
1333+ }
1334+ } ) ;
1335+ assert ! ( thread_started_is_memory_consolidation( & value) ) ;
1336+ }
1337+
1338+ #[ test]
1339+ fn thread_started_memory_consolidation_rejects_non_memory_subagent ( ) {
1340+ let value = json ! ( {
1341+ "method" : "thread/started" ,
1342+ "params" : {
1343+ "thread" : {
1344+ "id" : "thread-1" ,
1345+ "source" : {
1346+ "subAgent" : "review"
1347+ }
1348+ }
1349+ }
1350+ } ) ;
1351+ assert ! ( !thread_started_is_memory_consolidation( & value) ) ;
1352+ }
1353+
1354+ #[ test]
1355+ fn hidden_thread_suppression_allows_rpc_responses ( ) {
1356+ assert ! ( !should_suppress_hidden_thread_event( Some ( "thread/archived" ) , true ) ) ;
1357+ assert ! ( !should_suppress_hidden_thread_event( Some ( "thread/updated" ) , true ) ) ;
1358+ assert ! ( !should_suppress_hidden_thread_event( None , true ) ) ;
1359+ }
1360+
1361+ #[ test]
1362+ fn hidden_thread_suppression_still_blocks_non_exempt_notifications ( ) {
1363+ assert ! ( should_suppress_hidden_thread_event(
1364+ Some ( "thread/updated" ) ,
1365+ false
1366+ ) ) ;
1367+ assert ! ( !should_suppress_hidden_thread_event(
1368+ Some ( "thread/archived" ) ,
1369+ false
1370+ ) ) ;
1371+ assert ! ( !should_suppress_hidden_thread_event(
1372+ Some ( "codex/backgroundThread" ) ,
1373+ false
1374+ ) ) ;
1375+ }
11181376}
0 commit comments