Draw a background task as what it ran, and go there on a tap
A card in the session's panel said "background command" under every description -- and for Codex, which names a terminal by a process id and gives no description at all, that phrase was the whole of every card. Both halves of the answer are in the transcript rather than in what the provider says: a driver now reports which tool call its task belongs to (Claude's `task_started` carries the `tool_use_id`, Codex's terminal list the `itemId`), and `LiveSession::background_tasks` resolves those ids against the transcript into a sequence number and, where the provider said nothing, the command the call was made with. So the card draws the command, and the kind shrinks to a mark beside it whose name is what a screen reader is given. Tapping one goes to that call in the transcript, opened, which is where a backgrounded command's output already lands -- rather than drawing a second copy of it beside the panel. The journey is the one a reopened session already makes to put a reader back where they stopped, now one function (`travelTo`). It has to release the held backlog first: events arriving while the reader is away from the newest end are held rather than applied, so a task started since they scrolled back was in no row at all and the tap looked like it had done nothing. Verified against the sandbox on the emulator: the panel draws `sleep 120 && echo done` for an echo session's `/background`, and tapping it lands on that Bash card with its output showing.
This commit is contained in:
1 parent
3b309766d7
commit
cedb18e8c1
18 files changed
+635
-151
No files matched your search
@@ -674,19 +674,28 @@ impl Translator {
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|task| BackgroundTask {
|
||||
id: task
|
||||
.map(|task| {
|
||||
let id = task
|
||||
.get("task_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
description: text_field(task, "description"),
|
||||
kind: match task.get("task_type").and_then(Value::as_str) {
|
||||
Some("local_agent") => BackgroundTaskKind::Agent,
|
||||
Some("local_bash" | "local_shell") => BackgroundTaskKind::Command,
|
||||
Some("local_workflow") => BackgroundTaskKind::Workflow,
|
||||
_ => BackgroundTaskKind::Other,
|
||||
},
|
||||
.to_string();
|
||||
BackgroundTask {
|
||||
description: text_field(task, "description"),
|
||||
kind: match task.get("task_type").and_then(Value::as_str) {
|
||||
Some("local_agent") => BackgroundTaskKind::Agent,
|
||||
Some("local_bash" | "local_shell") => BackgroundTaskKind::Command,
|
||||
Some("local_workflow") => BackgroundTaskKind::Workflow,
|
||||
_ => BackgroundTaskKind::Other,
|
||||
},
|
||||
// The level's own ids are not promised to mean anything
|
||||
// outside it, so the call is the one `task_started`
|
||||
// carried for this task -- and a task whose start this
|
||||
// translator never saw, an adopted process's, simply has
|
||||
// none.
|
||||
call: self.tasks.get(&id).cloned(),
|
||||
id,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let count = live.len();
|
||||
@@ -1959,11 +1968,27 @@ mod tests {
|
||||
/// The panel lists these, so each entry is read rather than counted --
|
||||
/// and an `ambient` one is dropped from the list and the count alike,
|
||||
/// since a watcher counted as work leaves a session `waiting` for ever.
|
||||
///
|
||||
/// Each task also carries the call that started it, where this translator
|
||||
/// saw the `task_started` that named one: that is what the reader is
|
||||
/// taken to on tapping the card, and the level's own ids mean nothing
|
||||
/// outside it. A task whose start was never seen -- `t2` here -- has
|
||||
/// none, and is still listed.
|
||||
#[test]
|
||||
fn a_background_snapshot_describes_each_task_and_drops_ambient_ones() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
|
||||
|
||||
let started = json!({
|
||||
"type": "system",
|
||||
"subtype": "task_started",
|
||||
"task_id": "t1",
|
||||
"tool_use_id": "toolu_one",
|
||||
"task_type": "local_bash",
|
||||
})
|
||||
.to_string();
|
||||
assert_eq!(translate_lines(&mut translator, &[&started]), Vec::new());
|
||||
|
||||
let line = json!({
|
||||
"type": "system",
|
||||
"subtype": "background_tasks_changed",
|
||||
@@ -1985,11 +2010,13 @@ mod tests {
|
||||
id: "t1".to_string(),
|
||||
description: Some("run the tests".to_string()),
|
||||
kind: BackgroundTaskKind::Command,
|
||||
call: Some("toolu_one".to_string()),
|
||||
},
|
||||
BackgroundTask {
|
||||
id: "t2".to_string(),
|
||||
description: Some("review the diff".to_string()),
|
||||
kind: BackgroundTaskKind::Agent,
|
||||
call: None,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
+59
-20
@@ -9,7 +9,7 @@
|
||||
|
||||
mod translate;
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -38,7 +38,11 @@ const STATE_FILE: &str = "codex-state.json";
|
||||
const POLL: std::time::Duration = std::time::Duration::from_millis(50);
|
||||
const BACKGROUND_POLL: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
type BackgroundProcesses = Arc<Mutex<HashSet<String>>>;
|
||||
/// The background terminals app-server says are alive, by process id, each
|
||||
/// against the transcript item whose call started it where the list named
|
||||
/// one. The item id is what the panel draws a command rather than a process
|
||||
/// number for, and what takes a reader to the call.
|
||||
pub(super) type BackgroundProcesses = Arc<Mutex<BTreeMap<String, Option<String>>>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct BackgroundQuery {
|
||||
@@ -49,7 +53,7 @@ struct BackgroundQuery {
|
||||
struct PendingBackgroundQuery {
|
||||
request: String,
|
||||
thread: String,
|
||||
found: HashSet<String>,
|
||||
found: BTreeMap<String, Option<String>>,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
@@ -194,7 +198,7 @@ impl CodexDriver {
|
||||
}
|
||||
});
|
||||
|
||||
let background_processes = Arc::new(Mutex::new(HashSet::new()));
|
||||
let background_processes = Arc::new(Mutex::new(BTreeMap::new()));
|
||||
let inner = Arc::new(Inner {
|
||||
sink,
|
||||
state: Mutex::new(state),
|
||||
@@ -672,16 +676,25 @@ pub(super) fn background_tasks(
|
||||
id,
|
||||
description: Some(title),
|
||||
kind: BackgroundTaskKind::Agent,
|
||||
// A subagent is opened from the list below this one, which is
|
||||
// where its own transcript is read; the call that spawned it is
|
||||
// not what a reader of this row wants.
|
||||
call: None,
|
||||
})
|
||||
.collect();
|
||||
let mut running: Vec<String> = processes.lock().unwrap().iter().cloned().collect();
|
||||
running.sort();
|
||||
tasks.extend(running.into_iter().map(|id| BackgroundTask {
|
||||
// In process-id order, which is the order the map holds them in: the list
|
||||
// itself is a snapshot with no order of its own, and two fetches must not
|
||||
// disagree about how it reads.
|
||||
let running = processes.lock().unwrap().clone();
|
||||
tasks.extend(running.into_iter().map(|(id, item)| BackgroundTask {
|
||||
id,
|
||||
// The terminal list carries a process id and no name, and a number
|
||||
// drawn as a name is worse than the panel saying it does not know.
|
||||
// What the panel draws instead is the command, resolved from the item
|
||||
// below by whoever answers the route.
|
||||
description: None,
|
||||
kind: BackgroundTaskKind::Command,
|
||||
call: item,
|
||||
}));
|
||||
tasks
|
||||
}
|
||||
@@ -707,7 +720,7 @@ fn refresh_background_terminals(inner: &Inner) {
|
||||
query.pending = Some(PendingBackgroundQuery {
|
||||
request: request.clone(),
|
||||
thread: thread.clone(),
|
||||
found: HashSet::new(),
|
||||
found: BTreeMap::new(),
|
||||
dirty: false,
|
||||
});
|
||||
request
|
||||
@@ -755,11 +768,17 @@ fn handle_background_response(inner: &Inner, line: &Value) -> bool {
|
||||
query.pending = None;
|
||||
return true;
|
||||
}
|
||||
for process in terminals
|
||||
.iter()
|
||||
.filter_map(|terminal| terminal.get("processId").and_then(Value::as_str))
|
||||
{
|
||||
pending.found.insert(process.to_string());
|
||||
for terminal in terminals {
|
||||
let Some(process) = terminal.get("processId").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
pending.found.insert(
|
||||
process.to_string(),
|
||||
terminal
|
||||
.get("itemId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
);
|
||||
}
|
||||
if let Some(cursor) = line.pointer("/result/nextCursor").and_then(Value::as_str) {
|
||||
let thread = pending.thread.clone();
|
||||
@@ -1582,7 +1601,7 @@ mod tests {
|
||||
transport: Transport::Here,
|
||||
session_dir: dir.path().to_path_buf(),
|
||||
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
|
||||
background_processes: Arc::new(Mutex::new(HashSet::new())),
|
||||
background_processes: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
background_query: Mutex::new(BackgroundQuery::default()),
|
||||
reading: AtomicBool::new(true),
|
||||
};
|
||||
@@ -1598,7 +1617,7 @@ mod tests {
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"data": [
|
||||
{"processId": "process-a"},
|
||||
{"processId": "process-a", "itemId": "item-a"},
|
||||
{"processId": "process-b"}
|
||||
],
|
||||
"nextCursor": null
|
||||
@@ -1606,6 +1625,26 @@ mod tests {
|
||||
})
|
||||
));
|
||||
assert_eq!(background_task_count(&inner), 2);
|
||||
// The item the terminal belongs to is what the panel draws a command
|
||||
// rather than a process number for, so it is kept beside the id;
|
||||
// a terminal listed without one is still a task.
|
||||
assert_eq!(
|
||||
background_tasks(&inner.subagents, &inner.background_processes),
|
||||
vec![
|
||||
BackgroundTask {
|
||||
id: "process-a".to_string(),
|
||||
description: None,
|
||||
kind: BackgroundTaskKind::Command,
|
||||
call: Some("item-a".to_string()),
|
||||
},
|
||||
BackgroundTask {
|
||||
id: "process-b".to_string(),
|
||||
description: None,
|
||||
kind: BackgroundTaskKind::Command,
|
||||
call: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
events.try_recv().expect("count"),
|
||||
Event::BackgroundTasks { count: 2 }
|
||||
@@ -1691,7 +1730,7 @@ mod tests {
|
||||
transport: Transport::Here,
|
||||
session_dir: dir.path().to_path_buf(),
|
||||
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
|
||||
background_processes: Arc::new(Mutex::new(HashSet::new())),
|
||||
background_processes: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
background_query: Mutex::new(BackgroundQuery::default()),
|
||||
reading: AtomicBool::new(true),
|
||||
});
|
||||
@@ -1748,7 +1787,7 @@ mod tests {
|
||||
transport: Transport::Here,
|
||||
session_dir: dir.path().to_path_buf(),
|
||||
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
|
||||
background_processes: Arc::new(Mutex::new(HashSet::new())),
|
||||
background_processes: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
background_query: Mutex::new(BackgroundQuery::default()),
|
||||
reading: AtomicBool::new(true),
|
||||
});
|
||||
@@ -1815,7 +1854,7 @@ mod tests {
|
||||
transport: Transport::Here,
|
||||
session_dir: dir.path().to_path_buf(),
|
||||
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
|
||||
background_processes: Arc::new(Mutex::new(HashSet::new())),
|
||||
background_processes: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
background_query: Mutex::new(BackgroundQuery::default()),
|
||||
reading: AtomicBool::new(true),
|
||||
});
|
||||
@@ -1880,7 +1919,7 @@ mod tests {
|
||||
transport: Transport::Here,
|
||||
session_dir: dir.path().to_path_buf(),
|
||||
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
|
||||
background_processes: Arc::new(Mutex::new(HashSet::new())),
|
||||
background_processes: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
background_query: Mutex::new(BackgroundQuery::default()),
|
||||
reading: AtomicBool::new(true),
|
||||
});
|
||||
@@ -1951,7 +1990,7 @@ mod tests {
|
||||
transport: Transport::Here,
|
||||
session_dir: dir.path().to_path_buf(),
|
||||
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
|
||||
background_processes: Arc::new(Mutex::new(HashSet::new())),
|
||||
background_processes: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
background_query: Mutex::new(BackgroundQuery::default()),
|
||||
reading: AtomicBool::new(true),
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! ignore the rest; an added Codex item must not make a live session go deaf.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -18,7 +18,7 @@ pub(super) struct Translator {
|
||||
completed: bool,
|
||||
limited: bool,
|
||||
subagents: Option<Arc<Subagents>>,
|
||||
background_processes: Option<Arc<Mutex<HashSet<String>>>>,
|
||||
background_processes: Option<super::BackgroundProcesses>,
|
||||
children: HashMap<String, Translator>,
|
||||
prompts: HashMap<String, String>,
|
||||
async_messages: HashSet<String>,
|
||||
@@ -28,7 +28,7 @@ pub(super) struct Translator {
|
||||
impl Translator {
|
||||
pub(super) fn new(
|
||||
subagents: Arc<Subagents>,
|
||||
background_processes: Arc<Mutex<HashSet<String>>>,
|
||||
background_processes: super::BackgroundProcesses,
|
||||
thread_id: Option<String>,
|
||||
in_turn: bool,
|
||||
) -> Self {
|
||||
@@ -912,6 +912,9 @@ fn find_reset(value: &Value) -> Option<f64> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn line(text: &str) -> Value {
|
||||
@@ -1231,7 +1234,7 @@ mod tests {
|
||||
fn codex_subagents_get_their_own_transcripts_and_hold_the_parent_waiting() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Arc::new(Subagents::new(dir.path().to_path_buf()));
|
||||
let background_processes = Arc::new(Mutex::new(HashSet::new()));
|
||||
let background_processes = Arc::new(Mutex::new(BTreeMap::new()));
|
||||
let mut translator = Translator::new(
|
||||
Arc::clone(&subagents),
|
||||
Arc::clone(&background_processes),
|
||||
@@ -1330,7 +1333,7 @@ mod tests {
|
||||
fn codex_reports_each_change_to_its_live_background_count() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Arc::new(Subagents::new(dir.path().to_path_buf()));
|
||||
let background_processes = Arc::new(Mutex::new(HashSet::new()));
|
||||
let background_processes = Arc::new(Mutex::new(BTreeMap::new()));
|
||||
let mut translator = Translator::new(
|
||||
Arc::clone(&subagents),
|
||||
Arc::clone(&background_processes),
|
||||
@@ -1357,7 +1360,7 @@ mod tests {
|
||||
background_processes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("command-a".to_string());
|
||||
.insert("command-a".to_string(), None);
|
||||
for (child, count) in [("child-a", 2), ("child-b", 1)] {
|
||||
let events = translator.translate(&json!({
|
||||
"method": "item/completed",
|
||||
@@ -1521,7 +1524,7 @@ mod tests {
|
||||
subagents.start("child-thread", "child", Some("work"));
|
||||
let mut translator = Translator::new(
|
||||
Arc::clone(&subagents),
|
||||
Arc::new(Mutex::new(HashSet::new())),
|
||||
Arc::new(Mutex::new(BTreeMap::new())),
|
||||
Some("parent-thread".to_string()),
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -791,21 +791,28 @@ pub type EventSink = mpsc::UnboundedSender<Event>;
|
||||
/// leave going.
|
||||
///
|
||||
/// Runtime state, never written to a transcript: it is what the provider
|
||||
/// says right now, so a session with no process has nothing to say. Served
|
||||
/// by `GET /sessions/{id}/background`; the `BackgroundTasks` event carries
|
||||
/// only the size, which is what the status row draws.
|
||||
/// says right now, so a session with no process has nothing to say. What
|
||||
/// `GET /sessions/{id}/background` answers is this resolved against the
|
||||
/// transcript -- see [`BackgroundTaskView`](crate::session::BackgroundTaskView);
|
||||
/// the `BackgroundTasks` event carries only the size, which is what the
|
||||
/// status row draws.
|
||||
///
|
||||
/// [`description`](Self::description) is `None` where the provider names a
|
||||
/// task by something no reader would recognise -- a process id -- rather
|
||||
/// than by a sentence worked out here; the phone says it does not know.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
/// than by a sentence worked out here. Where [`call`](Self::call) is known
|
||||
/// the transcript answers it instead, from the call's own arguments; see
|
||||
/// [`LiveSession::background_tasks`](crate::session::LiveSession::background_tasks).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackgroundTask {
|
||||
/// The provider's own id for it. Never shown; it is what makes two
|
||||
/// snapshots comparable, and what keys the list on the phone.
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub kind: BackgroundTaskKind,
|
||||
/// The id of the tool call that started it, where the provider says
|
||||
/// which. `None` is a provider that does not, or one whose account of
|
||||
/// the start was never seen -- an adopted process mid-task.
|
||||
pub call: Option<String>,
|
||||
}
|
||||
|
||||
/// What kind of thing a [`BackgroundTask`] is, in the terms the app draws.
|
||||
|
||||
@@ -509,8 +509,13 @@ impl EchoDriver {
|
||||
let mut tasks = background_tasks.lock().unwrap();
|
||||
tasks.push(BackgroundTask {
|
||||
id: id.clone(),
|
||||
description: Some(command.clone()),
|
||||
// Deliberately unsaid, though this driver knows it: the
|
||||
// command is on the call above, and leaving it to be
|
||||
// resolved from there is what makes `/background` exercise
|
||||
// the path a real provider's process id takes.
|
||||
description: None,
|
||||
kind: BackgroundTaskKind::Command,
|
||||
call: Some(id.clone()),
|
||||
});
|
||||
self.emit(Event::BackgroundTasks { count: tasks.len() });
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub mod subagent;
|
||||
pub mod transcript;
|
||||
pub mod transport;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -36,8 +36,8 @@ use crate::config::{
|
||||
use claude::ClaudeDriver;
|
||||
use codex::CodexDriver;
|
||||
use driver::{
|
||||
AttachmentRef, BackgroundTask, Driver, Event, EventSink, Images, SessionCommand, SessionStatus,
|
||||
Unqueued, context_after, context_limit_after,
|
||||
AttachmentRef, BackgroundTaskKind, Driver, Event, EventSink, Images, SessionCommand,
|
||||
SessionStatus, Unqueued, context_after, context_limit_after,
|
||||
};
|
||||
use echo::EchoDriver;
|
||||
use llama::LlamaDriver;
|
||||
@@ -170,6 +170,48 @@ fn resume_message(meta: &SessionConfig) -> String {
|
||||
.unwrap_or_else(|| DEFAULT_RESUME_MESSAGE.to_string())
|
||||
}
|
||||
|
||||
/// One background task as `GET /sessions/{id}/background` answers it: what
|
||||
/// the provider said about it, plus where the transcript says it began.
|
||||
///
|
||||
/// A view rather than a [`driver::BackgroundTask`] with more fields on it, because
|
||||
/// the two are answered by different things -- a driver knows what is
|
||||
/// running and cannot know where a sequence number is, and only this side
|
||||
/// has read the transcript.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackgroundTaskView {
|
||||
pub id: String,
|
||||
/// What it is doing: the provider's own words, or -- for one that names
|
||||
/// a task by a process id -- the command its call was made with.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub kind: BackgroundTaskKind,
|
||||
/// Where in the transcript this was started, for the reader who taps it.
|
||||
/// Absent where the call is not in the transcript, or where the provider
|
||||
/// never said which call it was.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub call: Option<CallSite>,
|
||||
}
|
||||
|
||||
/// Where one tool call is. A struct of one field, so that adding what a
|
||||
/// caller needs next does not mean another optional beside `seq` that is
|
||||
/// only meaningful when it is set.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallSite {
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
/// The command a call was made with, where it was made with one -- every way
|
||||
/// a backgrounded command reaches a transcript here (`Bash`, Codex's `Shell`)
|
||||
/// has the whole of it under `command`. A call with no such argument keeps
|
||||
/// its `None` rather than being described by some other one it happens to
|
||||
/// have, which would read as a command that was never run.
|
||||
fn command_of(input: &serde_json::Value) -> Option<String> {
|
||||
let command = input.get("command")?.as_str()?.trim();
|
||||
(!command.is_empty()).then(|| command.to_string())
|
||||
}
|
||||
|
||||
/// One row of `GET /sessions`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -528,8 +570,41 @@ impl LiveSession {
|
||||
/// What this session has running in the background, as its provider last
|
||||
/// said -- `None` when nothing has said, which includes a session with no
|
||||
/// process. Serves `GET /sessions/{id}/background`.
|
||||
pub fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
|
||||
self.driver().and_then(|driver| driver.background_tasks())
|
||||
///
|
||||
/// Each task is resolved against the transcript here rather than in the
|
||||
/// driver, because both things the resolution produces are facts about
|
||||
/// the transcript and not about the provider: where the call that started
|
||||
/// the task is, which is where tapping the card takes the reader, and --
|
||||
/// for a provider that names a task by a process id -- the arguments that
|
||||
/// call was made with, which is the command the panel draws instead of
|
||||
/// "background task" repeated down the list.
|
||||
pub fn background_tasks(&self) -> Option<Vec<BackgroundTaskView>> {
|
||||
let tasks = self.driver().and_then(|driver| driver.background_tasks())?;
|
||||
let wanted: HashSet<String> = tasks.iter().filter_map(|task| task.call.clone()).collect();
|
||||
// A transcript that cannot be read costs the calls, not the list: the
|
||||
// tasks themselves are what the panel is for, and they are all still
|
||||
// here.
|
||||
let calls =
|
||||
transcript::locate_tool_calls(&self.transcript_path, &wanted).unwrap_or_else(|err| {
|
||||
tracing::warn!("locating background tasks' calls: {err:#}");
|
||||
HashMap::new()
|
||||
});
|
||||
Some(
|
||||
tasks
|
||||
.into_iter()
|
||||
.map(|task| {
|
||||
let found = task.call.as_deref().and_then(|id| calls.get(id));
|
||||
BackgroundTaskView {
|
||||
description: task
|
||||
.description
|
||||
.or_else(|| found.and_then(|call| command_of(&call.input))),
|
||||
call: found.map(|call| CallSite { seq: call.seq }),
|
||||
id: task.id,
|
||||
kind: task.kind,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// What this session is doing right now, as the pump last recorded it --
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! Reopening an existing file continues the numbering, which is what makes
|
||||
//! a backend restart invisible to a phone holding a cursor.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::ops::Range;
|
||||
@@ -240,6 +241,59 @@ pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
|
||||
indexed.parse(start..indexed.lines.len())
|
||||
}
|
||||
|
||||
/// Where each of `ids` was called, for the ids that are in the transcript:
|
||||
/// the call's sequence number and the arguments it was made with.
|
||||
///
|
||||
/// What this is for is a background task: a provider names the tool call that
|
||||
/// started one, and both the words the panel draws it with and the place the
|
||||
/// reader is taken on tapping it come from the call itself. Nothing else here
|
||||
/// answers "where is this id", because nothing else needed to -- every other
|
||||
/// reader of a transcript wants a range of it.
|
||||
///
|
||||
/// Walked newest-first and stopped as soon as every id is found, since a task
|
||||
/// that is still running was started recently: the whole file is only parsed
|
||||
/// for an id that is not in it at all. The substring test before each parse is
|
||||
/// what keeps that worst case a scan of the text rather than 24,000 parses.
|
||||
pub fn locate_tool_calls(path: &Path, ids: &HashSet<String>) -> Result<HashMap<String, ToolCall>> {
|
||||
let mut found = HashMap::new();
|
||||
if ids.is_empty() {
|
||||
return Ok(found);
|
||||
}
|
||||
let Some(indexed) = Indexed::read(path)? else {
|
||||
return Ok(found);
|
||||
};
|
||||
for index in (0..indexed.lines.len()).rev() {
|
||||
let line = &indexed.text[indexed.lines[index].clone()];
|
||||
if !ids.iter().any(|id| line.contains(id.as_str())) {
|
||||
continue;
|
||||
}
|
||||
let entry = indexed.parse_one(index)?;
|
||||
let Event::ToolStart { id, input, .. } = entry.event else {
|
||||
continue;
|
||||
};
|
||||
if !ids.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
found.insert(
|
||||
id,
|
||||
ToolCall {
|
||||
seq: entry.seq,
|
||||
input,
|
||||
},
|
||||
);
|
||||
if found.len() == ids.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// One tool call as [`locate_tool_calls`] found it.
|
||||
pub struct ToolCall {
|
||||
pub seq: u64,
|
||||
pub input: serde_json::Value,
|
||||
}
|
||||
|
||||
/// The transcript's lines located but not read, so a reader can find the range
|
||||
/// it wants and parse only that.
|
||||
///
|
||||
@@ -586,6 +640,53 @@ mod tests {
|
||||
assert!(read_after(&path, 3).expect("read").is_empty());
|
||||
}
|
||||
|
||||
/// What a background task's card is drawn from and what tapping it moves
|
||||
/// to. An id that was never a tool call -- or one whose call is a kind
|
||||
/// this never recorded arguments for -- is simply absent, which is the
|
||||
/// card with nothing to go to rather than an error.
|
||||
#[test]
|
||||
fn a_tool_call_is_found_by_its_id_and_a_stranger_is_not() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
transcript.append(text("before"), 1.0).expect("append");
|
||||
for id in ["call-one", "call-two"] {
|
||||
transcript
|
||||
.append(
|
||||
Event::ToolStart {
|
||||
id: id.to_string(),
|
||||
tool: "Bash".to_string(),
|
||||
input: serde_json::json!({"command": format!("run {id}")}),
|
||||
},
|
||||
2.0,
|
||||
)
|
||||
.expect("append");
|
||||
}
|
||||
transcript.append(text("after"), 3.0).expect("append");
|
||||
|
||||
let wanted = ["call-two", "never-called"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let found = locate_tool_calls(&path, &wanted).expect("locate");
|
||||
assert_eq!(found.len(), 1);
|
||||
let call = found.get("call-two").expect("the call that was made");
|
||||
assert_eq!(call.seq, 3);
|
||||
assert_eq!(call.input["command"], "run call-two");
|
||||
|
||||
assert!(
|
||||
locate_tool_calls(&path, &HashSet::new())
|
||||
.expect("locate")
|
||||
.is_empty()
|
||||
);
|
||||
let missing = dir.path().join("not-a-transcript.jsonl");
|
||||
assert!(
|
||||
locate_tool_calls(&missing, &wanted)
|
||||
.expect("locate")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_continues_the_numbering() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
Reference in new issue
Block a user