11use super :: * ;
22use std:: collections:: { BTreeSet , HashMap } ;
3- use std:: sync:: Arc ;
4- use std:: sync:: atomic :: { AtomicBool , Ordering } ;
3+ use std:: sync:: atomic :: { AtomicBool , AtomicUsize , Ordering } ;
4+ use std:: sync:: { Arc , Mutex } ;
55
6+ use async_trait:: async_trait;
67use axum:: body:: Body ;
78use futures_util:: StreamExt ;
89use garyx_bridge:: MultiProviderBridge ;
@@ -18,7 +19,9 @@ use garyx_models::provider::{
1819} ;
1920use garyx_models:: thread_logs:: { ThreadLogEvent , ThreadLogSink } ;
2021use garyx_models:: { RenderDelta , apply_render_delta, render_rows_digest} ;
21- use garyx_router:: { MessageRouter , RunTranscriptRecordDraft } ;
22+ use garyx_router:: {
23+ InMemoryThreadStore , MessageRouter , RunTranscriptRecordDraft , ThreadStore , ThreadStoreError ,
24+ } ;
2225use std:: path:: Path ;
2326use std:: process:: Command ;
2427use tempfile:: { TempDir , tempdir} ;
@@ -40,6 +43,69 @@ fn authed_request() -> axum::http::request::Builder {
4043 crate :: test_support:: authed_request ( )
4144}
4245
46+ struct RouteStoreProbe {
47+ inner : Arc < dyn ThreadStore > ,
48+ list_calls : AtomicUsize ,
49+ get_calls : Mutex < Vec < String > > ,
50+ }
51+
52+ impl RouteStoreProbe {
53+ fn new ( inner : Arc < dyn ThreadStore > ) -> Self {
54+ Self {
55+ inner,
56+ list_calls : AtomicUsize :: new ( 0 ) ,
57+ get_calls : Mutex :: new ( Vec :: new ( ) ) ,
58+ }
59+ }
60+
61+ fn reset_observations ( & self ) {
62+ self . list_calls . store ( 0 , Ordering :: SeqCst ) ;
63+ self . get_calls
64+ . lock ( )
65+ . expect ( "route store probe lock" )
66+ . clear ( ) ;
67+ }
68+
69+ fn get_calls ( & self ) -> Vec < String > {
70+ self . get_calls
71+ . lock ( )
72+ . expect ( "route store probe lock" )
73+ . clone ( )
74+ }
75+ }
76+
77+ #[ async_trait]
78+ impl ThreadStore for RouteStoreProbe {
79+ async fn get ( & self , thread_id : & str ) -> Option < Value > {
80+ self . get_calls
81+ . lock ( )
82+ . expect ( "route store probe lock" )
83+ . push ( thread_id. to_owned ( ) ) ;
84+ self . inner . get ( thread_id) . await
85+ }
86+
87+ async fn set ( & self , thread_id : & str , data : Value ) {
88+ self . inner . set ( thread_id, data) . await ;
89+ }
90+
91+ async fn delete ( & self , thread_id : & str ) -> bool {
92+ self . inner . delete ( thread_id) . await
93+ }
94+
95+ async fn list_keys ( & self , prefix : Option < & str > ) -> Vec < String > {
96+ self . list_calls . fetch_add ( 1 , Ordering :: SeqCst ) ;
97+ self . inner . list_keys ( prefix) . await
98+ }
99+
100+ async fn exists ( & self , thread_id : & str ) -> bool {
101+ self . inner . exists ( thread_id) . await
102+ }
103+
104+ async fn update ( & self , thread_id : & str , updates : Value ) -> Result < ( ) , ThreadStoreError > {
105+ self . inner . update ( thread_id, updates) . await
106+ }
107+ }
108+
43109/// Every message-ref id a wire `render_state.rows` tree references, in
44110/// tree order — the "which messages does this frame render" oracle.
45111fn render_state_ref_ids ( render_state : & Value ) -> Vec < String > {
@@ -3355,6 +3421,88 @@ async fn recent_threads_route_filters_tasks_and_preserves_default_response() {
33553421 ) ;
33563422}
33573423
3424+ #[ tokio:: test]
3425+ async fn recent_threads_tasks_exclude_never_scans_thread_store ( ) {
3426+ let store = Arc :: new ( RouteStoreProbe :: new ( Arc :: new ( InMemoryThreadStore :: new ( ) ) ) ) ;
3427+ let state = AppStateBuilder :: new ( test_config ( ) )
3428+ . with_thread_store ( store. clone ( ) )
3429+ . build ( ) ;
3430+ for ( thread_id, thread_type, timestamp) in [
3431+ (
3432+ "thread::route-no-scan-task" ,
3433+ "task" ,
3434+ "2026-05-23T14:00:00Z" ,
3435+ ) ,
3436+ (
3437+ "thread::route-no-scan-chat" ,
3438+ "chat" ,
3439+ "2026-05-23T13:00:00Z" ,
3440+ ) ,
3441+ ] {
3442+ state
3443+ . threads
3444+ . thread_store
3445+ . set (
3446+ thread_id,
3447+ json ! ( {
3448+ "thread_id" : thread_id,
3449+ "thread_kind" : thread_type,
3450+ "label" : format!( "Title for {thread_id}" ) ,
3451+ "updated_at" : timestamp,
3452+ } ) ,
3453+ )
3454+ . await ;
3455+ state
3456+ . ops
3457+ . garyx_db
3458+ . upsert_recent_thread ( RecentThreadDraft {
3459+ thread_id : thread_id. to_owned ( ) ,
3460+ title : format ! ( "Title for {thread_id}" ) ,
3461+ workspace_dir : Some ( "/workspace/test" . to_owned ( ) ) ,
3462+ thread_type : thread_type. to_owned ( ) ,
3463+ provider_type : None ,
3464+ agent_id : None ,
3465+ message_count : 0 ,
3466+ last_message_preview : String :: new ( ) ,
3467+ recent_run_id : None ,
3468+ active_run_id : None ,
3469+ run_state : "idle" . to_owned ( ) ,
3470+ updated_at : Some ( timestamp. to_owned ( ) ) ,
3471+ last_active_at : timestamp. to_owned ( ) ,
3472+ } )
3473+ . expect ( "seed recent projection" ) ;
3474+ }
3475+ store. reset_observations ( ) ;
3476+ let router = build_router ( state) ;
3477+
3478+ let response = router
3479+ . oneshot (
3480+ authed_request ( )
3481+ . uri ( "/api/recent-threads?limit=10&offset=0&tasks=exclude" )
3482+ . body ( Body :: empty ( ) )
3483+ . unwrap ( ) ,
3484+ )
3485+ . await
3486+ . unwrap ( ) ;
3487+ assert_eq ! ( response. status( ) , StatusCode :: OK ) ;
3488+ let body = axum:: body:: to_bytes ( response. into_body ( ) , 1024 * 1024 )
3489+ . await
3490+ . unwrap ( ) ;
3491+ let payload: Value = serde_json:: from_slice ( & body) . unwrap ( ) ;
3492+ assert_eq ! ( payload[ "total" ] , 1 ) ;
3493+ assert_eq ! ( payload[ "threads" ] [ 0 ] [ "thread_id" ] , "thread::route-no-scan-chat" ) ;
3494+ assert_eq ! (
3495+ store. list_calls. load( Ordering :: SeqCst ) ,
3496+ 0 ,
3497+ "recent task filtering must never enumerate thread-store keys"
3498+ ) ;
3499+ assert_eq ! (
3500+ store. get_calls( ) ,
3501+ vec![ "thread::route-no-scan-chat" ] ,
3502+ "task rows must be filtered by SQL before per-result runtime point reads"
3503+ ) ;
3504+ }
3505+
33583506#[ tokio:: test]
33593507async fn recent_threads_route_removes_hidden_threads_from_projection ( ) {
33603508 let state = AppStateBuilder :: new ( test_config ( ) ) . build ( ) ;
0 commit comments