Skip to content

Commit c8fed80

Browse files
authored
Merge pull request #65 from altaidevorg/feat/altai-scoped-inbox-queries
feat(memory): scope inbox list queries by channel
2 parents 799531c + 7a4bde1 commit c8fed80

5 files changed

Lines changed: 189 additions & 2 deletions

File tree

src/agent/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2170,6 +2170,11 @@ impl AgentLogic {
21702170
let _ = memory_node
21712171
.send_packet(MemoryMessage::ListBackgroundJobs {
21722172
chat_id: Some(chat_id.to_string()),
2173+
// A background job may have been created through a different
2174+
// channel for this same chat. Native recovery deliberately
2175+
// remains chat-scoped; host embedders opt into channel scope
2176+
// when their UI needs an isolated inbox.
2177+
channel: None,
21732178
limit: 10,
21742179
reply: SharedReply::new(tx),
21752180
})
@@ -2184,6 +2189,7 @@ impl AgentLogic {
21842189
.send_packet(MemoryMessage::ListClarificationTickets {
21852190
job_id: Some(job.job_id.clone()),
21862191
chat_id: Some(chat_id.to_string()),
2192+
channel: None,
21872193
status: Some("waiting".to_string()),
21882194
limit: 1,
21892195
reply: SharedReply::new(tx2),

src/channels/api.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1957,6 +1957,8 @@ struct JobsQuery {
19571957
#[serde(default)]
19581958
chat_id: Option<String>,
19591959
#[serde(default)]
1960+
channel: Option<String>,
1961+
#[serde(default)]
19601962
limit: Option<usize>,
19611963
}
19621964

@@ -1965,6 +1967,8 @@ struct NotificationsQuery {
19651967
#[serde(default)]
19661968
chat_id: Option<String>,
19671969
#[serde(default)]
1970+
channel: Option<String>,
1971+
#[serde(default)]
19681972
limit: Option<usize>,
19691973
#[serde(default)]
19701974
unseen_only: Option<bool>,
@@ -2373,6 +2377,7 @@ async fn handle_list_background_jobs(
23732377
let res = memory_request(&state.memory_node, |reply| {
23742378
MemoryMessage::ListBackgroundJobs {
23752379
chat_id: params.chat_id,
2380+
channel: params.channel,
23762381
limit: params.limit.unwrap_or(100),
23772382
reply,
23782383
}
@@ -2392,6 +2397,7 @@ async fn handle_list_notifications(
23922397
let res = memory_request(&state.memory_node, |reply| {
23932398
MemoryMessage::ListNotifications {
23942399
chat_id: params.chat_id,
2400+
channel: params.channel,
23952401
limit: params.limit.unwrap_or(100),
23962402
unseen_only: params.unseen_only.unwrap_or(false),
23972403
reply,

src/channels/terminal_ui/run.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1424,6 +1424,10 @@ pub(crate) fn run_ratatui_main(config: RatatuiMainConfig) -> io::Result<()> {
14241424
let _ = memory_node
14251425
.send_packet(crate::memory::MemoryMessage::ListBackgroundJobs {
14261426
chat_id: None,
1427+
// The terminal's inbox is intentionally
1428+
// workspace-global: jobs may originate in a
1429+
// different channel and must stay visible.
1430+
channel: None,
14271431
limit: 50,
14281432
reply: crate::memory::SharedReply::new(jtx),
14291433
})
@@ -1437,6 +1441,7 @@ pub(crate) fn run_ratatui_main(config: RatatuiMainConfig) -> io::Result<()> {
14371441
let _ = memory_node
14381442
.send_packet(crate::memory::MemoryMessage::ListNotifications {
14391443
chat_id: None,
1444+
channel: None,
14401445
limit: 50,
14411446
unseen_only: false,
14421447
reply: crate::memory::SharedReply::new(ntx),

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1341,6 +1341,7 @@ async fn recover_background_jobs_on_startup(
13411341
if memory_node
13421342
.send_packet(MemoryMessage::ListBackgroundJobs {
13431343
chat_id: None,
1344+
channel: None,
13441345
limit: 500,
13451346
reply: SharedReply::new(tx),
13461347
})

src/memory.rs

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -657,8 +657,11 @@ pub enum MemoryMessage {
657657
record: BackgroundJobRecord,
658658
reply: SharedReply<Result<(), String>>,
659659
},
660+
/// List background jobs, optionally scoped to one host channel before the
661+
/// result limit is applied.
660662
ListBackgroundJobs {
661663
chat_id: Option<String>,
664+
channel: Option<String>,
662665
limit: usize,
663666
reply: SharedReply<Result<Vec<BackgroundJobRecord>, String>>,
664667
},
@@ -672,8 +675,11 @@ pub enum MemoryMessage {
672675
record: NotificationRecord,
673676
reply: SharedReply<Result<(), String>>,
674677
},
678+
/// List notifications, optionally scoped to one host channel before the
679+
/// result limit is applied.
675680
ListNotifications {
676681
chat_id: Option<String>,
682+
channel: Option<String>,
677683
limit: usize,
678684
unseen_only: bool,
679685
reply: SharedReply<Result<Vec<NotificationRecord>, String>>,
@@ -705,10 +711,12 @@ pub enum MemoryMessage {
705711
response: String,
706712
reply: SharedReply<Result<(), String>>,
707713
},
708-
/// List clarification tickets with optional filters.
714+
/// List clarification tickets with optional filters. `channel` is applied
715+
/// in SQLite before the limit so one host cannot starve another's inbox.
709716
ListClarificationTickets {
710717
job_id: Option<String>,
711718
chat_id: Option<String>,
719+
channel: Option<String>,
712720
status: Option<String>,
713721
limit: usize,
714722
reply: SharedReply<Result<Vec<ClarificationTicketRecord>, String>>,
@@ -1869,6 +1877,7 @@ impl ActorLogic<MemoryMessage> for SqliteMemoryActor {
18691877
}
18701878
MemoryMessage::ListBackgroundJobs {
18711879
chat_id,
1880+
channel,
18721881
limit,
18731882
reply,
18741883
} => {
@@ -1886,6 +1895,10 @@ impl ActorLogic<MemoryMessage> for SqliteMemoryActor {
18861895
filters.push("chat_id = ?");
18871896
params_vec.push(Box::new(cid));
18881897
}
1898+
if let Some(ch) = channel {
1899+
filters.push("channel = ?");
1900+
params_vec.push(Box::new(ch));
1901+
}
18891902

18901903
if !filters.is_empty() {
18911904
sql.push_str(" WHERE ");
@@ -1955,6 +1968,7 @@ impl ActorLogic<MemoryMessage> for SqliteMemoryActor {
19551968
}
19561969
MemoryMessage::ListNotifications {
19571970
chat_id,
1971+
channel,
19581972
limit,
19591973
unseen_only,
19601974
reply,
@@ -1969,6 +1983,10 @@ impl ActorLogic<MemoryMessage> for SqliteMemoryActor {
19691983
filters.push("chat_id = ?");
19701984
params_vec.push(Box::new(cid));
19711985
}
1986+
if let Some(ch) = channel {
1987+
filters.push("channel = ?");
1988+
params_vec.push(Box::new(ch));
1989+
}
19721990
if unseen_only {
19731991
filters.push("seen_at_ms IS NULL");
19741992
}
@@ -2183,6 +2201,7 @@ impl ActorLogic<MemoryMessage> for SqliteMemoryActor {
21832201
MemoryMessage::ListClarificationTickets {
21842202
job_id,
21852203
chat_id,
2204+
channel,
21862205
status,
21872206
limit,
21882207
reply,
@@ -2201,6 +2220,10 @@ impl ActorLogic<MemoryMessage> for SqliteMemoryActor {
22012220
filters.push("chat_id = ?");
22022221
params_vec.push(Box::new(cid));
22032222
}
2223+
if let Some(ch) = channel {
2224+
filters.push("channel = ?");
2225+
params_vec.push(Box::new(ch));
2226+
}
22042227
if let Some(s) = status {
22052228
filters.push("status = ?");
22062229
params_vec.push(Box::new(s));
@@ -2298,7 +2321,8 @@ fn dropped_tool_call_ids(
22982321
#[cfg(test)]
22992322
mod root_thread_id_tests {
23002323
use super::{
2301-
chat_id_from_root_thread_id, is_root_session_thread_id, MemoryMessage, SharedReply,
2324+
chat_id_from_root_thread_id, is_root_session_thread_id, BackgroundJobRecord,
2325+
ClarificationTicketRecord, MemoryMessage, NotificationRecord, SharedReply,
23022326
SqliteMemoryActor,
23032327
};
23042328
use crate::session::SessionManager;
@@ -2339,6 +2363,151 @@ mod root_thread_id_tests {
23392363
assert!(!is_root_session_thread_id("tauri", "tauri:s-abc:extra:"));
23402364
}
23412365

2366+
#[tokio::test]
2367+
async fn channel_scoped_inbox_query_filters_before_limit() {
2368+
let actor = SqliteMemoryActor::new(":memory:").expect("memory actor");
2369+
let node = NodeHandle::new(actor, 16, 1, Duration::from_millis(1));
2370+
2371+
for (id, channel, created_at_ms) in [
2372+
("tauri-notification", "tauri", 1_i64),
2373+
("terminal-notification", "terminal", 2_i64),
2374+
] {
2375+
let (tx, rx) = tokio::sync::oneshot::channel();
2376+
node.send_packet(MemoryMessage::InsertNotification {
2377+
record: NotificationRecord {
2378+
notification_id: id.to_string(),
2379+
chat_id: "chat-1".to_string(),
2380+
channel: channel.to_string(),
2381+
thread_id: None,
2382+
kind: "test".to_string(),
2383+
title: "Test".to_string(),
2384+
body: "Test".to_string(),
2385+
action_kind: None,
2386+
action_payload: None,
2387+
seen_at_ms: None,
2388+
resolved_at_ms: None,
2389+
created_at_ms,
2390+
},
2391+
reply: SharedReply::new(tx),
2392+
})
2393+
.await
2394+
.expect("enqueue notification");
2395+
rx.await
2396+
.expect("notification actor reply")
2397+
.expect("insert notification");
2398+
}
2399+
2400+
let (tx, rx) = tokio::sync::oneshot::channel();
2401+
node.send_packet(MemoryMessage::ListNotifications {
2402+
chat_id: None,
2403+
channel: Some("tauri".to_string()),
2404+
limit: 1,
2405+
unseen_only: false,
2406+
reply: SharedReply::new(tx),
2407+
})
2408+
.await
2409+
.expect("enqueue list");
2410+
let rows = rx
2411+
.await
2412+
.expect("notification actor reply")
2413+
.expect("list notifications");
2414+
2415+
assert_eq!(rows.len(), 1);
2416+
assert_eq!(rows[0].notification_id, "tauri-notification");
2417+
2418+
for (id, channel, updated_at_ms) in [
2419+
("tauri-job", "tauri", 1_i64),
2420+
("terminal-job", "terminal", 2_i64),
2421+
] {
2422+
let (tx, rx) = tokio::sync::oneshot::channel();
2423+
node.send_packet(MemoryMessage::UpsertBackgroundJob {
2424+
record: BackgroundJobRecord {
2425+
job_id: id.to_string(),
2426+
kind: "test".to_string(),
2427+
chat_id: "chat-1".to_string(),
2428+
channel: channel.to_string(),
2429+
thread_id: None,
2430+
state: "waiting".to_string(),
2431+
payload_json: "{}".to_string(),
2432+
resume_after_restart: false,
2433+
detached: false,
2434+
last_error: None,
2435+
created_at_ms: updated_at_ms,
2436+
updated_at_ms,
2437+
},
2438+
reply: SharedReply::new(tx),
2439+
})
2440+
.await
2441+
.expect("enqueue background job");
2442+
rx.await
2443+
.expect("background job actor reply")
2444+
.expect("insert background job");
2445+
}
2446+
2447+
let (tx, rx) = tokio::sync::oneshot::channel();
2448+
node.send_packet(MemoryMessage::ListBackgroundJobs {
2449+
chat_id: None,
2450+
channel: Some("tauri".to_string()),
2451+
limit: 1,
2452+
reply: SharedReply::new(tx),
2453+
})
2454+
.await
2455+
.expect("enqueue background job list");
2456+
let jobs = rx
2457+
.await
2458+
.expect("background job actor reply")
2459+
.expect("list background jobs");
2460+
assert_eq!(jobs.len(), 1);
2461+
assert_eq!(jobs[0].job_id, "tauri-job");
2462+
2463+
for (id, channel, updated_at_ms) in [
2464+
("tauri-ticket", "tauri", 1_i64),
2465+
("terminal-ticket", "terminal", 2_i64),
2466+
] {
2467+
let (tx, rx) = tokio::sync::oneshot::channel();
2468+
node.send_packet(MemoryMessage::UpsertClarificationTicket {
2469+
record: ClarificationTicketRecord {
2470+
ticket_id: id.to_string(),
2471+
job_id: id.to_string(),
2472+
chat_id: "chat-1".to_string(),
2473+
channel: channel.to_string(),
2474+
thread_id: None,
2475+
tool_call_id: None,
2476+
prompt: "Test".to_string(),
2477+
choices_json: None,
2478+
response: None,
2479+
status: "waiting".to_string(),
2480+
created_at_ms: updated_at_ms,
2481+
updated_at_ms,
2482+
},
2483+
reply: SharedReply::new(tx),
2484+
})
2485+
.await
2486+
.expect("enqueue clarification ticket");
2487+
rx.await
2488+
.expect("clarification ticket actor reply")
2489+
.expect("insert clarification ticket");
2490+
}
2491+
2492+
let (tx, rx) = tokio::sync::oneshot::channel();
2493+
node.send_packet(MemoryMessage::ListClarificationTickets {
2494+
job_id: None,
2495+
chat_id: None,
2496+
channel: Some("tauri".to_string()),
2497+
status: None,
2498+
limit: 1,
2499+
reply: SharedReply::new(tx),
2500+
})
2501+
.await
2502+
.expect("enqueue clarification ticket list");
2503+
let tickets = rx
2504+
.await
2505+
.expect("clarification ticket actor reply")
2506+
.expect("list clarification tickets");
2507+
assert_eq!(tickets.len(), 1);
2508+
assert_eq!(tickets[0].ticket_id, "tauri-ticket");
2509+
}
2510+
23422511
#[tokio::test]
23432512
async fn get_context_returns_messages_in_insert_order() {
23442513
let actor = SqliteMemoryActor::new(":memory:").expect("memory actor");

0 commit comments

Comments
 (0)