use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use futures::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::net::UnixStream;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::WebSocketStream;
use super::{Agent, ObserveRequest, SessionEvent, SessionState};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
pub struct AppServerClient {
ws: WebSocketStream<UnixStream>,
next_id: u64,
}
impl AppServerClient {
pub async fn connect(path: &Path) -> Result<Self> {
let stream = UnixStream::connect(path)
.await
.with_context(|| format!("failed to connect to {}", path.display()))?;
let (ws, _) = tokio_tungstenite::client_async("ws://localhost/", stream)
.await
.context("app-server WebSocket handshake failed")?;
let mut client = Self { ws, next_id: 1 };
client
.request(
"initialize",
json!({
"clientInfo": {
"name": "omni-dev-codex-wrap",
"version": env!("CARGO_PKG_VERSION"),
},
}),
)
.await?;
client.notify("initialized").await?;
Ok(client)
}
async fn notify(&mut self, method: &str) -> Result<()> {
let message = json!({ "jsonrpc": "2.0", "method": method });
self.ws
.send(Message::text(message.to_string()))
.await
.context("app-server send failed")
}
async fn request(&mut self, method: &str, params: Value) -> Result<Value> {
let id = self.next_id;
self.next_id += 1;
let message = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
self.ws
.send(Message::text(message.to_string()))
.await
.context("app-server send failed")?;
tokio::time::timeout(REQUEST_TIMEOUT, async {
while let Some(frame) = self.ws.next().await {
let text = match frame.context("app-server receive failed")? {
Message::Text(text) => text,
Message::Close(_) => break,
_ => continue,
};
let Ok(reply) = serde_json::from_str::<Value>(text.as_str()) else {
continue;
};
if reply.get("method").is_some() || reply.get("id") != Some(&json!(id)) {
continue;
}
if let Some(error) = reply.get("error") {
bail!("app-server {method} failed: {error}");
}
return Ok(reply.get("result").cloned().unwrap_or(Value::Null));
}
Err(anyhow!("app-server closed the connection"))
})
.await
.map_err(|_| anyhow!("app-server {method} timed out"))?
}
pub async fn loaded_threads(&mut self) -> Result<Vec<String>> {
let result = self.request("thread/loaded/list", json!({})).await?;
Ok(result
.get("data")
.and_then(Value::as_array)
.map(|ids| {
ids.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default())
}
pub async fn read_thread(&mut self, id: &str) -> Result<Option<ThreadView>> {
let result = self
.request(
"thread/read",
json!({ "threadId": id, "includeTurns": false }),
)
.await?;
Ok(result.get("thread").and_then(ThreadView::from_json))
}
pub async fn snapshot(&mut self) -> Result<Snapshot> {
let loaded = self.loaded_threads().await?;
let mut views = Vec::new();
for id in &loaded {
if let Ok(Some(view)) = self.read_thread(id).await {
if !view.subagent {
views.push(view);
}
}
}
Ok(Snapshot { loaded, views })
}
}
#[derive(Debug, Clone, Default)]
pub struct Snapshot {
pub loaded: Vec<String>,
pub views: Vec<ThreadView>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThreadView {
pub id: String,
pub cwd: Option<PathBuf>,
pub model: Option<String>,
pub state: Option<SessionState>,
pub subagent: bool,
}
impl ThreadView {
fn from_json(thread: &Value) -> Option<Self> {
let id = thread.get("id")?.as_str()?.to_string();
let source = thread.get("source");
let subagent = thread.get("parentThreadId").is_some_and(|p| !p.is_null())
|| thread.get("ephemeral").and_then(Value::as_bool) == Some(true)
|| thread
.get("threadSource")
.and_then(Value::as_str)
.is_some_and(|s| s != "user")
|| source.is_some_and(Value::is_object)
|| source
.and_then(Value::as_str)
.is_some_and(|s| s.starts_with("subAgent"));
Some(Self {
id,
cwd: thread.get("cwd").and_then(Value::as_str).map(PathBuf::from),
model: thread
.get("model")
.and_then(Value::as_str)
.map(str::to_string),
state: thread.get("status").and_then(state_for_status),
subagent,
})
}
}
pub fn state_for_status(status: &Value) -> Option<SessionState> {
match status.get("type")?.as_str()? {
"idle" | "systemError" => Some(SessionState::Idle),
"active" => {
let flags: Vec<&str> = status
.get("activeFlags")
.and_then(Value::as_array)
.map(|flags| flags.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
Some(if flags.contains(&"waitingOnApproval") {
SessionState::WaitingForPermission
} else if flags.contains(&"waitingOnUserInput") {
SessionState::WaitingForInput
} else {
SessionState::Working
})
}
_ => None,
}
}
#[derive(Debug, Clone)]
pub enum Report {
Observe(ObserveRequest),
End(String),
}
#[derive(Debug, Clone)]
struct Known {
state: SessionState,
cwd: Option<PathBuf>,
model: Option<String>,
}
#[derive(Debug, Default)]
pub struct StatusTracker {
known: HashMap<String, Known>,
}
impl StatusTracker {
pub fn update(&mut self, snapshot: &Snapshot) -> Vec<Report> {
let mut reports = Vec::new();
let mut silent = Vec::new();
for view in &snapshot.views {
let Some(state) = view.state else {
silent.push(view.id.clone());
continue;
};
let known = Known {
state,
cwd: view.cwd.clone(),
model: view.model.clone(),
};
reports.push(Report::Observe(observe(&view.id, &known)));
self.known.insert(view.id.clone(), known);
}
let gone: Vec<String> = self
.known
.keys()
.filter(|id| !snapshot.loaded.contains(id) || silent.contains(id))
.cloned()
.collect();
for id in gone {
self.known.remove(&id);
reports.push(Report::End(id));
}
reports
}
pub fn finish(&mut self) -> Vec<Report> {
self.known.drain().map(|(id, _)| Report::End(id)).collect()
}
}
fn observe(id: &str, known: &Known) -> ObserveRequest {
ObserveRequest {
pid: None,
agent: Agent::Codex,
session_id: id.to_string(),
cwd: known.cwd.clone(),
transcript_path: None,
event: SessionEvent::StreamState(known.state),
repo: None,
model: known.model.clone(),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn view(id: &str, state: Option<SessionState>) -> ThreadView {
ThreadView {
id: id.to_string(),
cwd: Some(PathBuf::from("/work/repo")),
model: Some("gpt-5.5".to_string()),
state,
subagent: false,
}
}
fn summary(reports: &[Report]) -> Vec<String> {
reports
.iter()
.map(|r| match r {
Report::Observe(o) => format!("{}:{:?}", o.session_id, o.event),
Report::End(id) => format!("{id}:end"),
})
.collect()
}
#[test]
fn statuses_map_to_exact_states() {
let s = |v: Value| state_for_status(&v);
assert_eq!(s(json!({ "type": "idle" })), Some(SessionState::Idle));
assert_eq!(
s(json!({ "type": "systemError" })),
Some(SessionState::Idle)
);
assert_eq!(
s(json!({ "type": "active", "activeFlags": [] })),
Some(SessionState::Working)
);
assert_eq!(
s(json!({ "type": "active", "activeFlags": ["waitingOnApproval"] })),
Some(SessionState::WaitingForPermission)
);
assert_eq!(
s(json!({ "type": "active", "activeFlags": ["waitingOnUserInput"] })),
Some(SessionState::WaitingForInput)
);
assert_eq!(
s(
json!({ "type": "active", "activeFlags": ["waitingOnUserInput", "waitingOnApproval"] })
),
Some(SessionState::WaitingForPermission)
);
assert_eq!(s(json!({ "type": "notLoaded" })), None);
assert_eq!(s(json!({ "type": "somethingNew" })), None);
assert_eq!(s(json!({})), None);
}
#[test]
fn thread_views_read_the_fields_and_flag_subagents() {
let top = ThreadView::from_json(&json!({
"id": "t1",
"cwd": "/work/repo",
"model": "gpt-5.5",
"source": "vscode",
"threadSource": "user",
"ephemeral": false,
"status": { "type": "active", "activeFlags": ["waitingOnApproval"] },
"preview": "never read",
}))
.unwrap();
assert_eq!(top.id, "t1");
assert_eq!(top.cwd.as_deref(), Some(Path::new("/work/repo")));
assert_eq!(top.state, Some(SessionState::WaitingForPermission));
assert!(!top.subagent);
for sub in [
json!({ "id": "c", "parentThreadId": "t1", "status": { "type": "idle" } }),
json!({ "id": "c", "source": "subAgentThreadSpawn", "status": { "type": "idle" } }),
json!({ "id": "c", "source": { "subagent": "review" }, "status": { "type": "idle" } }),
json!({ "id": "c", "ephemeral": true, "source": "vscode", "threadSource": "system",
"status": { "type": "active", "activeFlags": [] } }),
] {
assert!(ThreadView::from_json(&sub).unwrap().subagent, "{sub}");
}
assert!(ThreadView::from_json(&json!({ "cwd": "/x" })).is_none());
}
fn snap(views: &[ThreadView]) -> Snapshot {
Snapshot {
loaded: views.iter().map(|v| v.id.clone()).collect(),
views: views.to_vec(),
}
}
#[test]
fn the_tracker_reasserts_every_poll_and_ends_unloaded_threads() {
let mut tracker = StatusTracker::default();
let idle = [view("t1", Some(SessionState::Idle))];
assert_eq!(
summary(&tracker.update(&snap(&idle))),
vec!["t1:StreamState(Idle)"]
);
assert_eq!(
summary(&tracker.update(&snap(&idle))),
vec!["t1:StreamState(Idle)"]
);
assert_eq!(
summary(&tracker.update(&snap(&[view("t2", Some(SessionState::Working))]))),
vec!["t2:StreamState(Working)", "t1:end"]
);
assert_eq!(
summary(&tracker.update(&snap(&[view("t2", None)]))),
vec!["t2:end"]
);
assert!(tracker.finish().is_empty());
}
#[test]
fn an_unreadable_but_loaded_thread_is_not_ended() {
let mut tracker = StatusTracker::default();
tracker.update(&snap(&[view("t1", Some(SessionState::Working))]));
let partial = Snapshot {
loaded: vec!["t1".to_string()],
views: Vec::new(),
};
assert!(tracker.update(&partial).is_empty());
let request = match &tracker.update(&snap(&[view("t1", Some(SessionState::Idle))]))[0] {
Report::Observe(request) => request.clone(),
Report::End(_) => panic!("expected an observe"),
};
assert_eq!(request.agent, Agent::Codex);
assert_eq!(request.model.as_deref(), Some("gpt-5.5"));
assert_eq!(summary(&tracker.finish()), vec!["t1:end"]);
assert!(tracker.finish().is_empty());
}
async fn fake_server(path: PathBuf, answered: std::sync::Arc<std::sync::Mutex<Vec<Value>>>) {
let listener = tokio::net::UnixListener::bind(&path).unwrap();
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
while let Some(Ok(Message::Text(text))) = ws.next().await {
let msg: Value = serde_json::from_str(text.as_str()).unwrap();
answered.lock().unwrap().push(msg.clone());
let Some(id) = msg.get("id").cloned() else {
continue;
};
let result = match msg["method"].as_str().unwrap() {
"initialize" => json!({ "userAgent": "fake" }),
"thread/loaded/list" => json!({ "data": ["t1", "child"] }),
"thread/read" if msg["params"]["threadId"] == "t1" => json!({ "thread": {
"id": "t1", "cwd": "/w", "source": "cli",
"status": { "type": "active", "activeFlags": ["waitingOnApproval"] },
} }),
"thread/read" => json!({ "thread": {
"id": "child", "parentThreadId": "t1", "status": { "type": "idle" },
} }),
other => panic!("unexpected {other}"),
};
let noise = [
json!({ "method": "thread/status/changed", "params": {} }),
json!({ "id": 900, "method": "item/commandExecution/requestApproval", "params": {} }),
json!({ "id": id, "result": result }),
];
for frame in noise {
ws.send(Message::text(frame.to_string())).await.unwrap();
}
}
}
#[tokio::test]
async fn the_client_polls_and_never_answers_a_server_request() {
let tmp = tempfile::tempdir_in("/tmp").unwrap();
let path = tmp.path().join("as.sock");
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let server = tokio::spawn(fake_server(path.clone(), seen.clone()));
while !path.exists() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
let mut client = AppServerClient::connect(&path).await.unwrap();
let snapshot = client.snapshot().await.unwrap();
assert_eq!(snapshot.loaded, ["t1", "child"]);
let views = snapshot.views;
assert_eq!(views.len(), 1, "the subagent thread is dropped");
assert_eq!(views[0].state, Some(SessionState::WaitingForPermission));
drop(client);
server.await.unwrap();
let methods: Vec<String> = seen
.lock()
.unwrap()
.iter()
.map(|m| m["method"].as_str().unwrap_or("<response>").to_string())
.collect();
assert_eq!(
methods,
[
"initialize",
"initialized",
"thread/loaded/list",
"thread/read",
"thread/read"
],
"only polls were sent — no response to the server's request"
);
}
}