Show a session's subagents as subcards, each with a read-only transcript
A subagent is a second transcript owned by a session, in the same event model, with no process and no controls. The claude translator routes lines carrying parent_tool_use_id to a per-subagent translator and transcript under <session>/subagents/<tool_use_id>; three routes expose the list, a transcript page and the SSE stream. Echo grows /subagent [n] as the rig. On the phone a card with subagents ends in a chevron expander, collapsed by default, opening to outlined subcards styled like dev-updater's components; a subcard opens SessionScreen in read-only form, addressed through TranscriptAddress so paging, cache and stream are shared. Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
eff5c8b0c0
commit
9fa09b0af1
21 files changed
+1953
-332
No files matched your search
+120
-13
@@ -29,6 +29,12 @@
|
||||
//! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent),
|
||||
//! ?limit=N, ?coalesce=true to count rows not deltas,
|
||||
//! ?after=N to floor it at what the caller already holds
|
||||
//! GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest
|
||||
//! first -- see SUBAGENTS.md
|
||||
//! GET /sessions/{id}/subagents/{sub}/transcript exactly the transcript route above,
|
||||
//! against that subagent's own transcript
|
||||
//! GET /sessions/{id}/subagents/{sub}/events?after=N exactly the events route above,
|
||||
//! against that subagent's own stream
|
||||
//! POST /sessions/{id}/message {text, attachmentIds?}
|
||||
//! (starts the process first if it has exited)
|
||||
//! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet
|
||||
@@ -99,6 +105,7 @@ use tokio_stream::wrappers::{BroadcastStream, ReceiverStream};
|
||||
|
||||
use crate::session::driver::{SessionCommand, Unqueued};
|
||||
use crate::session::pending::Operation;
|
||||
use crate::session::subagent::{Subagent, SubagentInfo};
|
||||
use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up};
|
||||
use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
|
||||
|
||||
@@ -130,6 +137,15 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
.route("/sessions/{id}", get(read_session).delete(delete_session))
|
||||
.route("/sessions/{id}/events", get(events))
|
||||
.route("/sessions/{id}/transcript", get(transcript))
|
||||
.route("/sessions/{id}/subagents", get(list_subagents))
|
||||
.route(
|
||||
"/sessions/{id}/subagents/{sub}/transcript",
|
||||
get(subagent_transcript),
|
||||
)
|
||||
.route(
|
||||
"/sessions/{id}/subagents/{sub}/events",
|
||||
get(subagent_events),
|
||||
)
|
||||
.route("/sessions/{id}/message", post(message))
|
||||
.route("/sessions/{id}/unqueue", post(unqueue))
|
||||
.route("/sessions/{id}/answer", post(answer))
|
||||
@@ -209,6 +225,18 @@ fn lookup(manager: &SessionManager, id: &str) -> Result<Arc<LiveSession>, ApiErr
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no session {id}")))
|
||||
}
|
||||
|
||||
/// A session's subagent by id -- the second half of the lookup every
|
||||
/// `/sessions/{id}/subagents/{sub}/...` route needs. `Arc` because reopening
|
||||
/// one from disk (a subagent this process has not touched yet) inserts it
|
||||
/// into the registry, and a route holding a borrow across that would be
|
||||
/// holding the registry's lock the whole request.
|
||||
fn lookup_subagent(session: &LiveSession, sub: &str) -> Result<Arc<Subagent>, ApiError> {
|
||||
session
|
||||
.subagents()
|
||||
.get(sub)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no subagent {sub}")))
|
||||
}
|
||||
|
||||
async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SessionInfo>> {
|
||||
axum::Json(manager.sessions())
|
||||
}
|
||||
@@ -1747,8 +1775,36 @@ async fn transcript(
|
||||
Query(query): Query<TranscriptQuery>,
|
||||
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> {
|
||||
let session = lookup(&manager, &id)?;
|
||||
transcript_page(session.transcript_path(), &id, query)
|
||||
}
|
||||
|
||||
/// Exactly [`transcript`]'s route and answer, against one subagent's own
|
||||
/// transcript instead of its session's -- see `SUBAGENTS.md`'s wire shape.
|
||||
async fn subagent_transcript(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath((id, sub)): UrlPath<(String, String)>,
|
||||
Query(query): Query<TranscriptQuery>,
|
||||
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> {
|
||||
let session = lookup(&manager, &id)?;
|
||||
let subagent = lookup_subagent(&session, &sub)?;
|
||||
transcript_page(
|
||||
&subagent.transcript_path(),
|
||||
&format!("{id}/subagents/{sub}"),
|
||||
query,
|
||||
)
|
||||
}
|
||||
|
||||
/// A page of history at `path`, newest first to open with -- the one
|
||||
/// implementation [`transcript`] and [`subagent_transcript`] share, since a
|
||||
/// subagent's transcript is read exactly the way a session's is. `label` is
|
||||
/// only for the debug line below.
|
||||
fn transcript_page(
|
||||
path: &Path,
|
||||
label: &str,
|
||||
query: TranscriptQuery,
|
||||
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> {
|
||||
let events = crate::session::transcript::read_window(
|
||||
session.transcript_path(),
|
||||
path,
|
||||
query.before,
|
||||
query.after,
|
||||
query.limit,
|
||||
@@ -1760,7 +1816,7 @@ async fn transcript(
|
||||
// for events and draws rows, and the ratio between them is a property of
|
||||
// the conversation. `RUST_LOG=ai_server=debug`.
|
||||
tracing::debug!(
|
||||
session = %id,
|
||||
session = %label,
|
||||
before = ?query.before,
|
||||
after = ?query.after,
|
||||
limit = query.limit,
|
||||
@@ -1778,23 +1834,74 @@ async fn events(
|
||||
headers: HeaderMap,
|
||||
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
|
||||
let session = lookup(&manager, &id)?;
|
||||
let cursor = headers
|
||||
.get("last-event-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(query.after);
|
||||
|
||||
let cursor = cursor_of(&headers, query.after);
|
||||
// Subscribe before reading the file so nothing can land in the gap
|
||||
// between replay and live; overlap is deduplicated by seq.
|
||||
let live = session.subscribe();
|
||||
let (tx, stream) = mpsc::channel(64);
|
||||
tokio::spawn(stream_session(
|
||||
Ok(sse_stream(
|
||||
session.transcript_path().to_path_buf(),
|
||||
cursor,
|
||||
live,
|
||||
tx,
|
||||
));
|
||||
Ok(Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default()))
|
||||
))
|
||||
}
|
||||
|
||||
/// Exactly [`events`]'s route and answer, against one subagent's own stream
|
||||
/// instead of its session's -- see `SUBAGENTS.md`'s wire shape.
|
||||
async fn subagent_events(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath((id, sub)): UrlPath<(String, String)>,
|
||||
Query(query): Query<EventsQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
|
||||
let session = lookup(&manager, &id)?;
|
||||
let subagent = lookup_subagent(&session, &sub)?;
|
||||
let cursor = cursor_of(&headers, query.after);
|
||||
let live = subagent.subscribe();
|
||||
Ok(sse_stream(subagent.transcript_path(), cursor, live))
|
||||
}
|
||||
|
||||
/// The cursor an SSE reconnect resumes from: the native `Last-Event-ID`
|
||||
/// takes precedence over the query parameter, same cursor either way.
|
||||
fn cursor_of(headers: &HeaderMap, query_after: u64) -> u64 {
|
||||
headers
|
||||
.get("last-event-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(query_after)
|
||||
}
|
||||
|
||||
/// Spawns the backlog-then-live task and wraps it as the response, the one
|
||||
/// piece [`events`] and [`subagent_events`] share.
|
||||
fn sse_stream(
|
||||
transcript: PathBuf,
|
||||
cursor: u64,
|
||||
live: broadcast::Receiver<SeqEvent>,
|
||||
) -> Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>> {
|
||||
let (tx, stream) = mpsc::channel(64);
|
||||
tokio::spawn(stream_session(transcript, cursor, live, tx));
|
||||
Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// `GET /sessions/{id}/subagents`: every subagent this session has started,
|
||||
/// oldest first, with a status read from its own transcript -- see
|
||||
/// `SUBAGENTS.md`'s wire shape. A subagent whose last status is `Running` is
|
||||
/// reported `Unknown` instead when the session itself is not running: its
|
||||
/// process was the session's, and a session with none has nothing left to
|
||||
/// ask.
|
||||
async fn list_subagents(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<axum::Json<Vec<SubagentInfo>>, ApiError> {
|
||||
let session = lookup(&manager, &id)?;
|
||||
// Anything but `Exited` or `Unknown` has a process behind it, which is
|
||||
// what decides whether a subagent still reading `Running` from its own
|
||||
// transcript can be believed -- see `SUBAGENTS.md`'s wire shape.
|
||||
let running = !matches!(
|
||||
session.status(),
|
||||
crate::session::driver::SessionStatus::Exited
|
||||
| crate::session::driver::SessionStatus::Unknown
|
||||
);
|
||||
Ok(axum::Json(session.subagents().list(running)))
|
||||
}
|
||||
|
||||
/// Every session's attention-wanting moments, on one stream.
|
||||
|
||||
Reference in new issue
Block a user