Skip to content

Commit 85f2c07

Browse files
committed
fix(app-server): suppress memory consolidation threads in thread routing
1 parent b4bd2d6 commit 85f2c07

4 files changed

Lines changed: 271 additions & 10 deletions

File tree

src-tauri/src/backend/app_server.rs

Lines changed: 268 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ fn normalize_root_path(value: &str) -> String {
207207
struct ThreadListEntry {
208208
thread_id: String,
209209
cwd: Option<String>,
210+
is_memory_consolidation: bool,
210211
}
211212

212213
fn 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+
297396
fn 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
}

src-tauri/src/bin/codex_monitor_daemon.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,6 +1665,7 @@ mod tests {
16651665
pending: Mutex::new(HashMap::new()),
16661666
request_context: Mutex::new(HashMap::new()),
16671667
thread_workspace: Mutex::new(HashMap::new()),
1668+
hidden_thread_ids: Mutex::new(HashSet::new()),
16681669
next_id: AtomicU64::new(0),
16691670
background_thread_callbacks: Mutex::new(HashMap::new()),
16701671
workspace_ids: Mutex::new(HashSet::from([owner_workspace_id.clone()])),

src-tauri/src/shared/workspaces_core/connect.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ mod tests {
174174
pending: Mutex::new(HashMap::new()),
175175
request_context: Mutex::new(HashMap::new()),
176176
thread_workspace: Mutex::new(HashMap::new()),
177+
hidden_thread_ids: Mutex::new(HashSet::new()),
177178
next_id: AtomicU64::new(0),
178179
background_thread_callbacks: Mutex::new(HashMap::new()),
179180
owner_workspace_id: "test-owner".to_string(),

0 commit comments

Comments
 (0)